From f666917fac1684aacd72046e0abd243d97555c36 Mon Sep 17 00:00:00 2001 From: Manuel David Villalba Escamilla Date: Wed, 15 Jul 2026 11:12:04 +0200 Subject: [PATCH 1/7] RH-0 - Add repository health foundation --- .github/PULL_REQUEST_TEMPLATE.md | 70 +- .github/dependabot.yml | 32 +- .github/workflows/ci.yml | 106 +- .gitignore | 484 +++--- CODE_OF_CONDUCT.md | 56 +- README.md | 650 ++++---- SECURITY.md | 62 +- SUPPORT.md | 46 +- docs/API_CONTRACT.md | 114 +- docs/BRANCHING.md | 132 +- docs/COMMANDS.md | 208 +-- docs/CONTRIBUTING.md | 80 +- docs/QUICKSTART.md | 146 +- docs/RELEASE_PROCESS.md | 150 +- docs/REPOSITORY_HEALTH.md | 102 +- docs/ROADMAP.md | 250 +-- docs/STATUS.md | 104 +- magicai/api/health.py | 242 +-- magicai/api/routes.py | 506 +++---- magicai/api/schemas.py | 328 ++-- magicai/versioning.py | 80 +- pyproject.toml | 48 +- scripts/ci_check.py | 160 +- scripts/export_github_analysis.sh | 1596 ++++++++++---------- scripts/package_release.py | 808 +++++----- scripts/update_scryfall_symbology.py | 120 +- tests/api/api_contract_metadata_test.py | 180 +-- tests/api/tactician_api_contract_test.py | 158 +- tests/repository/__init__.py | 2 +- tests/repository/release_packaging_test.py | 240 +-- tests/repository/repository_health_test.py | 236 +-- tests/ui/ui_usability_test.py | 344 ++--- uv.lock | 678 ++++----- 33 files changed, 4259 insertions(+), 4259 deletions(-) mode change 100755 => 100644 scripts/ci_check.py mode change 100755 => 100644 scripts/export_github_analysis.sh mode change 100755 => 100644 scripts/package_release.py diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 70a090a..1724d7f 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,35 +1,35 @@ -## Summary - -Describe what changed and why. - -## Scope - -- [ ] Application behavior -- [ ] Judge or factual authority -- [ ] Strategist behavior -- [ ] Source or retrieval logic -- [ ] API or UI contract -- [ ] Tests or quality infrastructure -- [ ] Documentation or repository health - -## Source-grounding impact - -Explain whether this change affects Oracle, rules, rulings, legality, source provenance, or the Judge–Strategist authority boundary. - -## Validation - -List the commands and campaigns executed. - -```text -python scripts/ci_check.py -``` - -- [ ] Focused tests pass -- [ ] `git diff --check` passes -- [ ] No large generated sources, reports, databases, logs, or secrets were added -- [ ] New behavior has a regression test -- [ ] Markdown documentation is written in English - -## Known limitations - -Document anything that was not tested or remains intentionally out of scope. +## Summary + +Describe what changed and why. + +## Scope + +- [ ] Application behavior +- [ ] Judge or factual authority +- [ ] Strategist behavior +- [ ] Source or retrieval logic +- [ ] API or UI contract +- [ ] Tests or quality infrastructure +- [ ] Documentation or repository health + +## Source-grounding impact + +Explain whether this change affects Oracle, rules, rulings, legality, source provenance, or the Judge–Strategist authority boundary. + +## Validation + +List the commands and campaigns executed. + +```text +python scripts/ci_check.py +``` + +- [ ] Focused tests pass +- [ ] `git diff --check` passes +- [ ] No large generated sources, reports, databases, logs, or secrets were added +- [ ] New behavior has a regression test +- [ ] Markdown documentation is written in English + +## Known limitations + +Document anything that was not tested or remains intentionally out of scope. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4505532..f362582 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,16 +1,16 @@ -version: 2 - -updates: - - package-ecosystem: "uv" - directory: "/" - target-branch: "develop" - schedule: - interval: "weekly" - open-pull-requests-limit: 5 - - - package-ecosystem: "github-actions" - directory: "/" - target-branch: "develop" - schedule: - interval: "weekly" - open-pull-requests-limit: 5 +version: 2 + +updates: + - package-ecosystem: "uv" + directory: "/" + target-branch: "develop" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + + - package-ecosystem: "github-actions" + directory: "/" + target-branch: "develop" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7f5b37..2a6c6df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,53 +1,53 @@ -name: CI - -on: - pull_request: - branches: - - main - - develop - push: - branches: - - main - - develop - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - focused-tests: - name: Focused tests (Python 3.12) - runs-on: ubuntu-latest - timeout-minutes: 15 - - steps: - - name: Check out repository - uses: actions/checkout@v7 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.12" - - - name: Install uv - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 - with: - enable-cache: true - - - name: Validate lock file - run: uv lock --check - - - name: Install locked dependencies - run: | - uv sync --frozen - uv pip check - - - name: Run repository and focused application checks - env: - MAGICAI_QUIET_EVALUATION: "1" - MAGICAI_CONVERSATION_DB: ${{ runner.temp }}/magicai-ci-conversations.sqlite3 - run: uv run --frozen python scripts/ci_check.py +name: CI + +on: + pull_request: + branches: + - main + - develop + push: + branches: + - main + - develop + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + focused-tests: + name: Focused tests (Python 3.12) + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install uv + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + with: + enable-cache: true + + - name: Validate lock file + run: uv lock --check + + - name: Install locked dependencies + run: | + uv sync --frozen + uv pip check + + - name: Run repository and focused application checks + env: + MAGICAI_QUIET_EVALUATION: "1" + MAGICAI_CONVERSATION_DB: ${{ runner.temp }}/magicai-ci-conversations.sqlite3 + run: uv run --frozen python scripts/ci_check.py diff --git a/.gitignore b/.gitignore index 56158c6..6881110 100644 --- a/.gitignore +++ b/.gitignore @@ -1,242 +1,242 @@ -# ============================================================================= -# MagicAI - .gitignore -# ============================================================================= - -# ----------------------------------------------------------------------------- -# Python: bytecode, caches and compiled extensions -# ----------------------------------------------------------------------------- -__pycache__/ -*.py[cod] -*$py.class -*.so -cython_debug/ - -# ----------------------------------------------------------------------------- -# Virtual environments and local Python tooling -# ----------------------------------------------------------------------------- -.venv/ -venv/ -env/ -ENV/ -.conda/ -.pixi/ -.direnv/ -__pypackages__/ -.python_env/ -pip-wheel-metadata/ -pip-log.txt -pip-delete-this-directory.txt - -# ----------------------------------------------------------------------------- -# Test, lint, type-checking, profiling and coverage artefacts -# ----------------------------------------------------------------------------- -.pytest_cache/ -.mypy_cache/ -.dmypy.json -dmypy.json -.pyre/ -.pytype/ -.pyright/ -.ruff_cache/ -.hypothesis/ -.tox/ -.nox/ -.benchmarks/ -.coverage -.coverage.* -coverage.xml -coverage.json -htmlcov/ -*.prof -*.lprof - -# ----------------------------------------------------------------------------- -# Python packaging and build outputs -# ----------------------------------------------------------------------------- -build/ -dist/ -develop-eggs/ -eggs/ -.eggs/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# ----------------------------------------------------------------------------- -# Environment variables, secrets and local configuration -# Keep example/template files versioned. -# ----------------------------------------------------------------------------- -.env -.env.* -!.env.example -!.env.template -.envrc -!.envrc.example -*.local - -# ----------------------------------------------------------------------------- -# Local databases and SQLite sidecar files -# ----------------------------------------------------------------------------- -*.db -*.db-journal -*.db-shm -*.db-wal -*.sqlite -*.sqlite-journal -*.sqlite-shm -*.sqlite-wal -*.sqlite3 -*.sqlite3-journal -*.sqlite3-shm -*.sqlite3-wal - -# ----------------------------------------------------------------------------- -# Logs, process files and runtime dumps -# ----------------------------------------------------------------------------- -*.log -*.log.* -*.pid -*.pid.lock -*.stackdump -nohup.out - -# ----------------------------------------------------------------------------- -# Temporary files and local caches -# ----------------------------------------------------------------------------- -.cache/ -.tmp/ -tmp/ -temp/ -*.tmp -*.temp -*.bak -*.orig -*.rej -*.swp -*.swo -*~ - -# ----------------------------------------------------------------------------- -# IDEs and editors -# ----------------------------------------------------------------------------- -.vscode/ -.idea/ -*.code-workspace -*.sublime-project -*.sublime-workspace -.project -.pydevproject -.settings/ -.ipynb_checkpoints/ - -# ----------------------------------------------------------------------------- -# Operating-system metadata -# ----------------------------------------------------------------------------- -.DS_Store -.AppleDouble -.LSOverride -._* -.Spotlight-V100 -.Trashes -Thumbs.db -Thumbs.db:encryptable -ehthumbs.db -Desktop.ini -$RECYCLE.BIN/ - -# ----------------------------------------------------------------------------- -# Scryfall bulk data -# These files are downloaded locally and exceed GitHub's file-size limit. -# Keep sources/scryfall/symbology.json versioned. -# ----------------------------------------------------------------------------- -/sources/scryfall/oracle-cards.json -/sources/scryfall/default-cards.json -/sources/scryfall/all-cards.json -/sources/scryfall/unique-artwork.json -/sources/scryfall/rulings.json -/sources/scryfall/bulk-data*.json -/sources/scryfall/*.json.part -/sources/scryfall/*.json.tmp - -# ----------------------------------------------------------------------------- -# MagicAI local runtime data -# ----------------------------------------------------------------------------- -/database/*.db -/database/*.sqlite -/database/*.sqlite3 - -# ----------------------------------------------------------------------------- -# Generated quality reports -# ----------------------------------------------------------------------------- -/Resultado.txt -/resultado_*.txt -/resultado_*.xml -/resultado_*.html -/resultado_*.json -/audit_gauntlet_*_failures.txt -/resultado_dynamic_gauntlet_failures/ -/resultado_dynamic_campaign/ - -# Common output directories when reports are redirected -/quality-results/ -/test-results/ -/reports/ -/tests/regression/output/ -/resultado_open_judge/ - -# ----------------------------------------------------------------------------- -# Local diagnostics, merge-conflict bundles and assistant patches -# ----------------------------------------------------------------------------- -/conflictos_git.txt -/conflictos_*.zip -/resolve_pr*_conflicts.sh -/sprint*.patch -/MagicAI-*.zip - -# ----------------------------------------------------------------------------- -# Backup and documentation build artefacts generated locally -# ----------------------------------------------------------------------------- -/backups/ -/backup/ -/site/ -/docs/_build/ - -# ----------------------------------------------------------------------------- -# Optional JavaScript tooling for the future UI -# ----------------------------------------------------------------------------- -node_modules/ -.npm/ -.pnpm-store/ -.yarn/cache/ -.yarn/unplugged/ -.next/ -.nuxt/ -.svelte-kit/ -.vite/ - -# ----------------------------------------------------------------------------- -# Git metadata accidentally included in exported source archives -# ----------------------------------------------------------------------------- -.git/ - -# Manual community feedback drafts and generated reports. -/community_feedback/inbox/* -!/community_feedback/inbox/.gitkeep -/community_feedback/reports/* -!/community_feedback/reports/.gitkeep -/resultado_community_feedback/ - -# Large dynamic development campaigns generated locally. -/resultado_dynamic-giant-*/ - -# Exhaustive Oracle evaluation sweeps. -/resultado_oracle_exhaustive*/ - -# Repository analysis exports and legacy root-level exporter. -/github-analysis-*/ -/export_github_analysis.sh - -# Clean release archives generated by scripts/package_release.py. -/dist/releases/ +# ============================================================================= +# MagicAI - .gitignore +# ============================================================================= + +# ----------------------------------------------------------------------------- +# Python: bytecode, caches and compiled extensions +# ----------------------------------------------------------------------------- +__pycache__/ +*.py[cod] +*$py.class +*.so +cython_debug/ + +# ----------------------------------------------------------------------------- +# Virtual environments and local Python tooling +# ----------------------------------------------------------------------------- +.venv/ +venv/ +env/ +ENV/ +.conda/ +.pixi/ +.direnv/ +__pypackages__/ +.python_env/ +pip-wheel-metadata/ +pip-log.txt +pip-delete-this-directory.txt + +# ----------------------------------------------------------------------------- +# Test, lint, type-checking, profiling and coverage artefacts +# ----------------------------------------------------------------------------- +.pytest_cache/ +.mypy_cache/ +.dmypy.json +dmypy.json +.pyre/ +.pytype/ +.pyright/ +.ruff_cache/ +.hypothesis/ +.tox/ +.nox/ +.benchmarks/ +.coverage +.coverage.* +coverage.xml +coverage.json +htmlcov/ +*.prof +*.lprof + +# ----------------------------------------------------------------------------- +# Python packaging and build outputs +# ----------------------------------------------------------------------------- +build/ +dist/ +develop-eggs/ +eggs/ +.eggs/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# ----------------------------------------------------------------------------- +# Environment variables, secrets and local configuration +# Keep example/template files versioned. +# ----------------------------------------------------------------------------- +.env +.env.* +!.env.example +!.env.template +.envrc +!.envrc.example +*.local + +# ----------------------------------------------------------------------------- +# Local databases and SQLite sidecar files +# ----------------------------------------------------------------------------- +*.db +*.db-journal +*.db-shm +*.db-wal +*.sqlite +*.sqlite-journal +*.sqlite-shm +*.sqlite-wal +*.sqlite3 +*.sqlite3-journal +*.sqlite3-shm +*.sqlite3-wal + +# ----------------------------------------------------------------------------- +# Logs, process files and runtime dumps +# ----------------------------------------------------------------------------- +*.log +*.log.* +*.pid +*.pid.lock +*.stackdump +nohup.out + +# ----------------------------------------------------------------------------- +# Temporary files and local caches +# ----------------------------------------------------------------------------- +.cache/ +.tmp/ +tmp/ +temp/ +*.tmp +*.temp +*.bak +*.orig +*.rej +*.swp +*.swo +*~ + +# ----------------------------------------------------------------------------- +# IDEs and editors +# ----------------------------------------------------------------------------- +.vscode/ +.idea/ +*.code-workspace +*.sublime-project +*.sublime-workspace +.project +.pydevproject +.settings/ +.ipynb_checkpoints/ + +# ----------------------------------------------------------------------------- +# Operating-system metadata +# ----------------------------------------------------------------------------- +.DS_Store +.AppleDouble +.LSOverride +._* +.Spotlight-V100 +.Trashes +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +Desktop.ini +$RECYCLE.BIN/ + +# ----------------------------------------------------------------------------- +# Scryfall bulk data +# These files are downloaded locally and exceed GitHub's file-size limit. +# Keep sources/scryfall/symbology.json versioned. +# ----------------------------------------------------------------------------- +/sources/scryfall/oracle-cards.json +/sources/scryfall/default-cards.json +/sources/scryfall/all-cards.json +/sources/scryfall/unique-artwork.json +/sources/scryfall/rulings.json +/sources/scryfall/bulk-data*.json +/sources/scryfall/*.json.part +/sources/scryfall/*.json.tmp + +# ----------------------------------------------------------------------------- +# MagicAI local runtime data +# ----------------------------------------------------------------------------- +/database/*.db +/database/*.sqlite +/database/*.sqlite3 + +# ----------------------------------------------------------------------------- +# Generated quality reports +# ----------------------------------------------------------------------------- +/Resultado.txt +/resultado_*.txt +/resultado_*.xml +/resultado_*.html +/resultado_*.json +/audit_gauntlet_*_failures.txt +/resultado_dynamic_gauntlet_failures/ +/resultado_dynamic_campaign/ + +# Common output directories when reports are redirected +/quality-results/ +/test-results/ +/reports/ +/tests/regression/output/ +/resultado_open_judge/ + +# ----------------------------------------------------------------------------- +# Local diagnostics, merge-conflict bundles and assistant patches +# ----------------------------------------------------------------------------- +/conflictos_git.txt +/conflictos_*.zip +/resolve_pr*_conflicts.sh +/sprint*.patch +/MagicAI-*.zip + +# ----------------------------------------------------------------------------- +# Backup and documentation build artefacts generated locally +# ----------------------------------------------------------------------------- +/backups/ +/backup/ +/site/ +/docs/_build/ + +# ----------------------------------------------------------------------------- +# Optional JavaScript tooling for the future UI +# ----------------------------------------------------------------------------- +node_modules/ +.npm/ +.pnpm-store/ +.yarn/cache/ +.yarn/unplugged/ +.next/ +.nuxt/ +.svelte-kit/ +.vite/ + +# ----------------------------------------------------------------------------- +# Git metadata accidentally included in exported source archives +# ----------------------------------------------------------------------------- +.git/ + +# Manual community feedback drafts and generated reports. +/community_feedback/inbox/* +!/community_feedback/inbox/.gitkeep +/community_feedback/reports/* +!/community_feedback/reports/.gitkeep +/resultado_community_feedback/ + +# Large dynamic development campaigns generated locally. +/resultado_dynamic-giant-*/ + +# Exhaustive Oracle evaluation sweeps. +/resultado_oracle_exhaustive*/ + +# Repository analysis exports and legacy root-level exporter. +/github-analysis-*/ +/export_github_analysis.sh + +# Clean release archives generated by scripts/package_release.py. +/dist/releases/ diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index eb0de0f..e637275 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,28 +1,28 @@ -# Code of conduct - -## Our standard - -MagicAI welcomes contributors, players, judges, developers, researchers, and documentation writers. Participation must remain respectful, constructive, and safe. - -Expected behavior includes: - -- discussing ideas and code without attacking people; -- giving specific, actionable feedback; -- respecting different experience levels, languages, identities, and play communities; -- acknowledging uncertainty and correcting mistakes openly; -- protecting private information and unpublished vulnerability details; -- crediting the work and sources of others. - -Unacceptable behavior includes harassment, discrimination, threats, deliberate misinformation, sexualized conduct, personal attacks, doxxing, spam, or attempts to pressure maintainers into unsafe releases. - -## Project-specific expectations - -Rules disagreements must be resolved through evidence and current authoritative sources, not status or popularity. Strategic disagreement is welcome when it remains clearly separated from factual authority. - -Evaluation artifacts, community examples, and reported failures must not be repurposed as training data without explicit permission and a documented policy change. - -## Enforcement - -Maintainers may edit, hide, lock, or remove contributions and may temporarily or permanently restrict participation when behavior violates this policy. - -Report conduct concerns privately through the same channel described in [SECURITY.md](SECURITY.md), while clearly identifying the report as a conduct matter rather than a software vulnerability. +# Code of conduct + +## Our standard + +MagicAI welcomes contributors, players, judges, developers, researchers, and documentation writers. Participation must remain respectful, constructive, and safe. + +Expected behavior includes: + +- discussing ideas and code without attacking people; +- giving specific, actionable feedback; +- respecting different experience levels, languages, identities, and play communities; +- acknowledging uncertainty and correcting mistakes openly; +- protecting private information and unpublished vulnerability details; +- crediting the work and sources of others. + +Unacceptable behavior includes harassment, discrimination, threats, deliberate misinformation, sexualized conduct, personal attacks, doxxing, spam, or attempts to pressure maintainers into unsafe releases. + +## Project-specific expectations + +Rules disagreements must be resolved through evidence and current authoritative sources, not status or popularity. Strategic disagreement is welcome when it remains clearly separated from factual authority. + +Evaluation artifacts, community examples, and reported failures must not be repurposed as training data without explicit permission and a documented policy change. + +## Enforcement + +Maintainers may edit, hide, lock, or remove contributions and may temporarily or permanently restrict participation when behavior violates this policy. + +Report conduct concerns privately through the same channel described in [SECURITY.md](SECURITY.md), while clearly identifying the report as a conduct matter rather than a software vulnerability. diff --git a/README.md b/README.md index 4c9ca1d..75d5e35 100644 --- a/README.md +++ b/README.md @@ -1,325 +1,325 @@ -
- -MagicAI - -# MagicAI - -### More Gathering. Less Guessing. - -**Local, source-grounded assistant for Magic: The Gathering** - -`v0.1.1-beta` — **Force of Will** · Local-first · Python + Ollama - -**Next public milestone:** `v0.2.0-beta` — **Ponder** -**Planned 1.0 codename:** **NicolAI Bolas** - -
- ---- - -## What is MagicAI? - -MagicAI is a local AI project for **Magic: The Gathering**. Its factual core is the **Judge**, which retrieves Oracle text, Comprehensive Rules, rulings, and conversation context before answering. - -> The model is not the source of truth. The Judge retrieves and validates evidence; language models explain it. - -MagicAI does not attempt to memorize every card or rule. It builds the evidence required for each question, uses deterministic renderers when a formal answer is covered, validates model output, and falls back safely when the evidence is insufficient. - -The second profile is the **Tactician**—shown as **Estratega** in the local UI. The Tactician analyzes game lines, interactions, synergies, and combos, but all factual data still passes through the Judge-owned source gateway. - -## Current capabilities - -- Local Scryfall Oracle and rulings snapshots. -- Local Magic Comprehensive Rules. -- Card, keyword, symbol, action, and rule retrieval. -- Conversation continuity and card disambiguation. -- Deterministic answers for covered rule families. -- Ollama fallback for explanations outside deterministic coverage. -- Source-grounded validation, retries, and safe fallback. -- Structured `JudgeResult` evidence and provenance. -- Local FastAPI REST API and browser UI. -- Persistent local conversation history in SQLite. -- Exportable community-feedback cases for evaluation only. -- Reproducible Gauntlets, multi-seed campaigns, process workers, sharding, and resume support. -- Tactician review of Judge contradictions. -- Automatic Judge-to-Tactician handoff for strategic questions. -- Referential follow-up support, including cards inherited from the previous turn. -- Initial generic combo reconstruction for sacrifice, Undying, counter-removal, token, and mana loops. - -## Card scope - -The standard Judge and evaluation catalog focus on ordinary paper cards. Funny, silver-border, acorn, and playtest cards are excluded, together with supplemental objects such as Vanguard cards, tokens, emblems, planes, phenomena, and schemes. Ordinary paper cards remain queryable even when they are currently banned. - -## Development status - -MagicAI is an early public beta with an advanced Judge core and an integrated Tactician foundation. The quality infrastructure is mature enough to expose semantic false positives instead of merely checking surface matches, but arbitrary Magic interactions are not yet fully covered. - -The latest focused C1.4 validation recorded: - -```text -Focused and expanded tests 231/231 -Full-Oracle smoke 42/42 -C1.3 finding replays 23/23 -Rebuilt campaign 1,000/1,000 -WARN 0 -FAIL 0 -``` - -These results describe a controlled and reproducible matrix. They do **not** mean every Magic card, rule, or interaction is covered. - -The current Tactician milestone adds: - -```text -Automatic strategic handoff -Conversation-card inheritance -Intent-specific strategy routing -Generic three-piece Undying loop detection -Judge capability registry -Structured combo steps and outcomes -``` - -See [docs/STATUS.md](docs/STATUS.md) for the current snapshot and [docs/ROADMAP.md](docs/ROADMAP.md) for the path to **Ponder**. - ---- - -## Project principles - -- **Judge authority:** the Judge is the sole factual authority for Oracle text, rules, rulings, and legality. -- **Tactician autonomy:** the Tactician may investigate iteratively and request as many Judge-owned tools as needed. -- **Source gateway:** strategic profiles never open factual sources directly. -- **Retrieve, do not memorize:** current sources take precedence over model memory. -- **No card-specific patches:** fixes must be generic, inspectable, and reusable. -- **Local-first:** inference runs through Ollama on the user's machine. -- **Safe uncertainty:** insufficient evidence is better than invented certainty. -- **Test the premise:** a correct answer is useless when the generated scenario is invalid. -- **Evaluation is not training:** reports and feedback artifacts never modify model weights automatically. - -See [docs/PHILOSOPHY.md](docs/PHILOSOPHY.md). - ---- - -## Architecture overview - -```text -User / API / UI - │ - ▼ -Conversation and intent routing - │ - ├──────── factual question ───────► Judge - │ │ - │ ├─ Oracle / Scryfall rulings - │ ├─ Comprehensive Rules - │ ├─ symbology / legality - │ └─ deterministic validation - │ - └──────── strategic question ─────► automatic handoff - │ - ▼ - Tactician - plan / combo / line - │ - ▼ - Judge source gateway - │ - ▼ - challenge / verify / critic - │ - ▼ - final answer -``` - -The Tactician may make repeated structured requests through the Judge. The source boundary is a trust boundary, not an intelligence limit. - -See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/TACTICIAN.md](docs/TACTICIAN.md). - ---- - -## Requirements - -- Python **3.12+** -- Ollama reachable over HTTP -- Recommended model: **Qwen3 8B** -- `curl`, `wget`, and `jq` for source download scripts -- Linux, WSL2, or an equivalent environment - -Default environment: - -```text -OLLAMA_URL=http://127.0.0.1:11434/api/chat -MAGICAI_MODEL=qwen3:8b -``` - ---- - -## Quick start - -```bash -git clone https://github.com/Fartis/MagicAI.git -cd MagicAI - -python3.12 -m venv .venv -source .venv/bin/activate -python -m pip install --upgrade pip -python -m pip install -r requirements.txt - -./scripts/download_sources.sh -./scripts/download_rules.sh -python scripts/update_scryfall_symbology.py - -ollama pull qwen3:8b -python -m uvicorn magicai.api:app --reload -``` - -Open: - -```text -UI http://127.0.0.1:8000/ui -API http://127.0.0.1:8000/docs -``` - -The complete setup guide, including `main` versus `develop`, Docker Ollama, and LAN Ollama, is in [docs/QUICKSTART.md](docs/QUICKSTART.md). - ---- - -## API profiles - -```text -POST /ask Judge entry point with automatic strategic handoff -POST /tactician/ask Explicit Tactician entry point -GET /meta contracts, profiles, codenames, and Judge capabilities -GET /health source and service health -``` - -Automatic handoff can be disabled for diagnostic clients: - -```json -{ - "question": "Do these cards form a combo?", - "auto_handoff": false -} -``` - -See [docs/API_CONTRACT.md](docs/API_CONTRACT.md) and [docs/JUDGE_RESULT.md](docs/JUDGE_RESULT.md). - ---- - -## Testing - -Fast pull request checks: - -```bash -python scripts/ci_check.py -``` - -Focused deterministic tests: - -```bash -PYTHONPATH=. python -m tests.validation.rule_renderer_test -PYTHONPATH=. python -m tests.validation.oracle_renderer_test -PYTHONPATH=. python -m tests.tactician.tactician_reviewer_test -PYTHONPATH=. python -m tests.tactician.tactician_strategy_test -PYTHONPATH=. python -m tests.tactician.tactician_conversation_handoff_test -``` - -Exhaustive Oracle evaluation: - -```bash -PYTHONPATH=. python -u -m tests.quality.oracle_exhaustive_test \ - --workers 4 \ - --shard-size 250 \ - --output-dir quality-results/oracle-exhaustive -``` - -See [docs/COMMANDS.md](docs/COMMANDS.md) and [docs/DEV_COMMANDS.md](docs/DEV_COMMANDS.md). - ---- - -## Main structure - -```text -MagicAI/ -├── magicai/ -│ ├── api/ # REST API -│ ├── assistant/ # Judge orchestration -│ ├── conversation/ # sessions and continuity -│ ├── judge_tools/ # Judge-owned capability registry -│ ├── retrieval/ # rule and Oracle query construction -│ ├── tactician/ # strategic analysis and challenges -│ ├── validation/ # renderers, validation, and fallback -│ └── ui/ # local browser UI -├── tests/ -├── docs/ -├── scripts/ -├── sources/ -└── database/ -``` - ---- - -## Release names - -- **0.1.1 beta — Force of Will:** the first public beta milestone. -- **0.2.0 beta — Ponder:** iterative Judge tools and a more autonomous Strategist. -- **1.0 — NicolAI Bolas:** the planned complete first major release. - -The codenames do not change MagicAI's source-grounded architecture or licensing. - ---- - -## Documentation - -- [Architecture](docs/ARCHITECTURE.md) -- [Quick start](docs/QUICKSTART.md) -- [Commands](docs/COMMANDS.md) -- [Developer commands](docs/DEV_COMMANDS.md) -- [UI](docs/UI.md) -- [Current status](docs/STATUS.md) -- [JudgeResult](docs/JUDGE_RESULT.md) -- [API contract](docs/API_CONTRACT.md) -- [Roadmap](docs/ROADMAP.md) -- [Tactician](docs/TACTICIAN.md) -- [Philosophy](docs/PHILOSOPHY.md) -- [Contributing](docs/CONTRIBUTING.md) -- [Branching policy](docs/BRANCHING.md) -- [Release process](docs/RELEASE_PROCESS.md) -- [Repository health](docs/REPOSITORY_HEALTH.md) - ---- - -## License - -MagicAI is distributed under the **GNU Affero General Public License v3.0 or later** (`AGPL-3.0-or-later`). See [LICENSE](LICENSE) and [docs/LICENSING.md](docs/LICENSING.md). - ---- - -# ❤️ A personal letter - -If you've made it this far, chances are we share the same passion. - -I'd like to finish this README with a few personal words. - -Due to health reasons, this will most likely be the last major software project I'll be able to build. After spending a large part of my professional life developing software, I've had to accept that my journey will take a different path much sooner than I ever expected. - -I wanted to say goodbye to this chapter by creating something that brought together the two things I've loved the most throughout my life: programming and **Magic: The Gathering**, a game that has been with me for as long as I can remember and has always meant far more than just a game. - -MagicAI was born from a simple idea: to build the tool I always wished I had, one that could help me understand the rules, organize my thoughts and continue enjoying this incredible game. - -As long as my health allows it, I'll continue improving it little by little, learning and adding new features whenever I can. - -If this project helps even a single player solve a rules question, discover a new interaction or simply enjoy Magic a little bit more, then it will have achieved everything I hoped for. - -Thank you for taking the time to discover this project. - -I truly hope you enjoy using it as much as I've enjoyed building it. - -**See you in the next game.** - - ---- - -
- -### 🧙 More Gathering. Less Guessing. - -
+
+ +MagicAI + +# MagicAI + +### More Gathering. Less Guessing. + +**Local, source-grounded assistant for Magic: The Gathering** + +`v0.1.1-beta` — **Force of Will** · Local-first · Python + Ollama + +**Next public milestone:** `v0.2.0-beta` — **Ponder** +**Planned 1.0 codename:** **NicolAI Bolas** + +
+ +--- + +## What is MagicAI? + +MagicAI is a local AI project for **Magic: The Gathering**. Its factual core is the **Judge**, which retrieves Oracle text, Comprehensive Rules, rulings, and conversation context before answering. + +> The model is not the source of truth. The Judge retrieves and validates evidence; language models explain it. + +MagicAI does not attempt to memorize every card or rule. It builds the evidence required for each question, uses deterministic renderers when a formal answer is covered, validates model output, and falls back safely when the evidence is insufficient. + +The second profile is the **Tactician**—shown as **Estratega** in the local UI. The Tactician analyzes game lines, interactions, synergies, and combos, but all factual data still passes through the Judge-owned source gateway. + +## Current capabilities + +- Local Scryfall Oracle and rulings snapshots. +- Local Magic Comprehensive Rules. +- Card, keyword, symbol, action, and rule retrieval. +- Conversation continuity and card disambiguation. +- Deterministic answers for covered rule families. +- Ollama fallback for explanations outside deterministic coverage. +- Source-grounded validation, retries, and safe fallback. +- Structured `JudgeResult` evidence and provenance. +- Local FastAPI REST API and browser UI. +- Persistent local conversation history in SQLite. +- Exportable community-feedback cases for evaluation only. +- Reproducible Gauntlets, multi-seed campaigns, process workers, sharding, and resume support. +- Tactician review of Judge contradictions. +- Automatic Judge-to-Tactician handoff for strategic questions. +- Referential follow-up support, including cards inherited from the previous turn. +- Initial generic combo reconstruction for sacrifice, Undying, counter-removal, token, and mana loops. + +## Card scope + +The standard Judge and evaluation catalog focus on ordinary paper cards. Funny, silver-border, acorn, and playtest cards are excluded, together with supplemental objects such as Vanguard cards, tokens, emblems, planes, phenomena, and schemes. Ordinary paper cards remain queryable even when they are currently banned. + +## Development status + +MagicAI is an early public beta with an advanced Judge core and an integrated Tactician foundation. The quality infrastructure is mature enough to expose semantic false positives instead of merely checking surface matches, but arbitrary Magic interactions are not yet fully covered. + +The latest focused C1.4 validation recorded: + +```text +Focused and expanded tests 231/231 +Full-Oracle smoke 42/42 +C1.3 finding replays 23/23 +Rebuilt campaign 1,000/1,000 +WARN 0 +FAIL 0 +``` + +These results describe a controlled and reproducible matrix. They do **not** mean every Magic card, rule, or interaction is covered. + +The current Tactician milestone adds: + +```text +Automatic strategic handoff +Conversation-card inheritance +Intent-specific strategy routing +Generic three-piece Undying loop detection +Judge capability registry +Structured combo steps and outcomes +``` + +See [docs/STATUS.md](docs/STATUS.md) for the current snapshot and [docs/ROADMAP.md](docs/ROADMAP.md) for the path to **Ponder**. + +--- + +## Project principles + +- **Judge authority:** the Judge is the sole factual authority for Oracle text, rules, rulings, and legality. +- **Tactician autonomy:** the Tactician may investigate iteratively and request as many Judge-owned tools as needed. +- **Source gateway:** strategic profiles never open factual sources directly. +- **Retrieve, do not memorize:** current sources take precedence over model memory. +- **No card-specific patches:** fixes must be generic, inspectable, and reusable. +- **Local-first:** inference runs through Ollama on the user's machine. +- **Safe uncertainty:** insufficient evidence is better than invented certainty. +- **Test the premise:** a correct answer is useless when the generated scenario is invalid. +- **Evaluation is not training:** reports and feedback artifacts never modify model weights automatically. + +See [docs/PHILOSOPHY.md](docs/PHILOSOPHY.md). + +--- + +## Architecture overview + +```text +User / API / UI + │ + ▼ +Conversation and intent routing + │ + ├──────── factual question ───────► Judge + │ │ + │ ├─ Oracle / Scryfall rulings + │ ├─ Comprehensive Rules + │ ├─ symbology / legality + │ └─ deterministic validation + │ + └──────── strategic question ─────► automatic handoff + │ + ▼ + Tactician + plan / combo / line + │ + ▼ + Judge source gateway + │ + ▼ + challenge / verify / critic + │ + ▼ + final answer +``` + +The Tactician may make repeated structured requests through the Judge. The source boundary is a trust boundary, not an intelligence limit. + +See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/TACTICIAN.md](docs/TACTICIAN.md). + +--- + +## Requirements + +- Python **3.12+** +- Ollama reachable over HTTP +- Recommended model: **Qwen3 8B** +- `curl`, `wget`, and `jq` for source download scripts +- Linux, WSL2, or an equivalent environment + +Default environment: + +```text +OLLAMA_URL=http://127.0.0.1:11434/api/chat +MAGICAI_MODEL=qwen3:8b +``` + +--- + +## Quick start + +```bash +git clone https://github.com/Fartis/MagicAI.git +cd MagicAI + +python3.12 -m venv .venv +source .venv/bin/activate +python -m pip install --upgrade pip +python -m pip install -r requirements.txt + +./scripts/download_sources.sh +./scripts/download_rules.sh +python scripts/update_scryfall_symbology.py + +ollama pull qwen3:8b +python -m uvicorn magicai.api:app --reload +``` + +Open: + +```text +UI http://127.0.0.1:8000/ui +API http://127.0.0.1:8000/docs +``` + +The complete setup guide, including `main` versus `develop`, Docker Ollama, and LAN Ollama, is in [docs/QUICKSTART.md](docs/QUICKSTART.md). + +--- + +## API profiles + +```text +POST /ask Judge entry point with automatic strategic handoff +POST /tactician/ask Explicit Tactician entry point +GET /meta contracts, profiles, codenames, and Judge capabilities +GET /health source and service health +``` + +Automatic handoff can be disabled for diagnostic clients: + +```json +{ + "question": "Do these cards form a combo?", + "auto_handoff": false +} +``` + +See [docs/API_CONTRACT.md](docs/API_CONTRACT.md) and [docs/JUDGE_RESULT.md](docs/JUDGE_RESULT.md). + +--- + +## Testing + +Fast pull request checks: + +```bash +python scripts/ci_check.py +``` + +Focused deterministic tests: + +```bash +PYTHONPATH=. python -m tests.validation.rule_renderer_test +PYTHONPATH=. python -m tests.validation.oracle_renderer_test +PYTHONPATH=. python -m tests.tactician.tactician_reviewer_test +PYTHONPATH=. python -m tests.tactician.tactician_strategy_test +PYTHONPATH=. python -m tests.tactician.tactician_conversation_handoff_test +``` + +Exhaustive Oracle evaluation: + +```bash +PYTHONPATH=. python -u -m tests.quality.oracle_exhaustive_test \ + --workers 4 \ + --shard-size 250 \ + --output-dir quality-results/oracle-exhaustive +``` + +See [docs/COMMANDS.md](docs/COMMANDS.md) and [docs/DEV_COMMANDS.md](docs/DEV_COMMANDS.md). + +--- + +## Main structure + +```text +MagicAI/ +├── magicai/ +│ ├── api/ # REST API +│ ├── assistant/ # Judge orchestration +│ ├── conversation/ # sessions and continuity +│ ├── judge_tools/ # Judge-owned capability registry +│ ├── retrieval/ # rule and Oracle query construction +│ ├── tactician/ # strategic analysis and challenges +│ ├── validation/ # renderers, validation, and fallback +│ └── ui/ # local browser UI +├── tests/ +├── docs/ +├── scripts/ +├── sources/ +└── database/ +``` + +--- + +## Release names + +- **0.1.1 beta — Force of Will:** the first public beta milestone. +- **0.2.0 beta — Ponder:** iterative Judge tools and a more autonomous Strategist. +- **1.0 — NicolAI Bolas:** the planned complete first major release. + +The codenames do not change MagicAI's source-grounded architecture or licensing. + +--- + +## Documentation + +- [Architecture](docs/ARCHITECTURE.md) +- [Quick start](docs/QUICKSTART.md) +- [Commands](docs/COMMANDS.md) +- [Developer commands](docs/DEV_COMMANDS.md) +- [UI](docs/UI.md) +- [Current status](docs/STATUS.md) +- [JudgeResult](docs/JUDGE_RESULT.md) +- [API contract](docs/API_CONTRACT.md) +- [Roadmap](docs/ROADMAP.md) +- [Tactician](docs/TACTICIAN.md) +- [Philosophy](docs/PHILOSOPHY.md) +- [Contributing](docs/CONTRIBUTING.md) +- [Branching policy](docs/BRANCHING.md) +- [Release process](docs/RELEASE_PROCESS.md) +- [Repository health](docs/REPOSITORY_HEALTH.md) + +--- + +## License + +MagicAI is distributed under the **GNU Affero General Public License v3.0 or later** (`AGPL-3.0-or-later`). See [LICENSE](LICENSE) and [docs/LICENSING.md](docs/LICENSING.md). + +--- + +# ❤️ A personal letter + +If you've made it this far, chances are we share the same passion. + +I'd like to finish this README with a few personal words. + +Due to health reasons, this will most likely be the last major software project I'll be able to build. After spending a large part of my professional life developing software, I've had to accept that my journey will take a different path much sooner than I ever expected. + +I wanted to say goodbye to this chapter by creating something that brought together the two things I've loved the most throughout my life: programming and **Magic: The Gathering**, a game that has been with me for as long as I can remember and has always meant far more than just a game. + +MagicAI was born from a simple idea: to build the tool I always wished I had, one that could help me understand the rules, organize my thoughts and continue enjoying this incredible game. + +As long as my health allows it, I'll continue improving it little by little, learning and adding new features whenever I can. + +If this project helps even a single player solve a rules question, discover a new interaction or simply enjoy Magic a little bit more, then it will have achieved everything I hoped for. + +Thank you for taking the time to discover this project. + +I truly hope you enjoy using it as much as I've enjoyed building it. + +**See you in the next game.** + + +--- + +
+ +### 🧙 More Gathering. Less Guessing. + +
diff --git a/SECURITY.md b/SECURITY.md index 39cdcc9..6abb76e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,31 +1,31 @@ -# Security policy - -## Supported versions - -MagicAI is under active beta development. Security fixes are applied to the latest published release and the current `develop` branch. Older alpha and beta snapshots are not maintained unless explicitly stated in their release notes. - -## Reporting a vulnerability - -Do not publish exploit details, credentials, private data, or reproducible attack steps in a public issue. - -Use GitHub's private vulnerability reporting feature for this repository when it is available. If private reporting is unavailable, open a minimal public issue asking the maintainers for a private contact channel. Include no sensitive technical details in that issue. - -A useful private report includes: - -- affected version or commit; -- affected component; -- reproduction steps; -- realistic impact; -- suggested mitigation, when known; -- whether the issue involves local files, network exposure, dependencies, or source data. - -## Security boundaries - -MagicAI is designed as a local-first application, but users remain responsible for their deployment environment. - -- Do not expose an unauthenticated Ollama endpoint to the public Internet. -- Do not commit `.env` files, credentials, private decklists, conversation databases, logs, or generated analysis bundles. -- Treat imported community data and user-supplied files as untrusted input. -- Keep Python, Ollama, MagicAI dependencies, and operating-system packages updated. - -Security reports are evaluated separately from ordinary rules-answer accuracy reports. +# Security policy + +## Supported versions + +MagicAI is under active beta development. Security fixes are applied to the latest published release and the current `develop` branch. Older alpha and beta snapshots are not maintained unless explicitly stated in their release notes. + +## Reporting a vulnerability + +Do not publish exploit details, credentials, private data, or reproducible attack steps in a public issue. + +Use GitHub's private vulnerability reporting feature for this repository when it is available. If private reporting is unavailable, open a minimal public issue asking the maintainers for a private contact channel. Include no sensitive technical details in that issue. + +A useful private report includes: + +- affected version or commit; +- affected component; +- reproduction steps; +- realistic impact; +- suggested mitigation, when known; +- whether the issue involves local files, network exposure, dependencies, or source data. + +## Security boundaries + +MagicAI is designed as a local-first application, but users remain responsible for their deployment environment. + +- Do not expose an unauthenticated Ollama endpoint to the public Internet. +- Do not commit `.env` files, credentials, private decklists, conversation databases, logs, or generated analysis bundles. +- Treat imported community data and user-supplied files as untrusted input. +- Keep Python, Ollama, MagicAI dependencies, and operating-system packages updated. + +Security reports are evaluated separately from ordinary rules-answer accuracy reports. diff --git a/SUPPORT.md b/SUPPORT.md index 7655cb6..c23b742 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -1,23 +1,23 @@ -# Support - -## Rules and application questions - -Use GitHub Discussions for general usage, architecture, rules-testing methodology, and community development topics when Discussions are available. - -Use GitHub Issues for reproducible bugs and scoped feature requests. Include: - -- MagicAI version or commit; -- operating system and Python version; -- Ollama server and client versions when relevant; -- the exact question or workflow; -- the structured result or exported feedback artifact; -- the expected behavior; -- logs only after removing private data. - -## Security and conduct - -Do not use ordinary issues for vulnerabilities, credentials, private data, or sensitive conduct reports. Follow [SECURITY.md](SECURITY.md) and [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). - -## Project scope - -MagicAI is a community project and not an official Wizards of the Coast, Scryfall, EDHREC, or Commander Spellbook service. The repository cannot provide official tournament rulings or emergency support. +# Support + +## Rules and application questions + +Use GitHub Discussions for general usage, architecture, rules-testing methodology, and community development topics when Discussions are available. + +Use GitHub Issues for reproducible bugs and scoped feature requests. Include: + +- MagicAI version or commit; +- operating system and Python version; +- Ollama server and client versions when relevant; +- the exact question or workflow; +- the structured result or exported feedback artifact; +- the expected behavior; +- logs only after removing private data. + +## Security and conduct + +Do not use ordinary issues for vulnerabilities, credentials, private data, or sensitive conduct reports. Follow [SECURITY.md](SECURITY.md) and [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). + +## Project scope + +MagicAI is a community project and not an official Wizards of the Coast, Scryfall, EDHREC, or Commander Spellbook service. The repository cannot provide official tournament rulings or emergency support. diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 8deae4e..b145e87 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -1,57 +1,57 @@ -# MagicAI HTTP contract - -## Versions - -- API contract: `1.2` -- JudgeResult schema: `1.0` -- TacticianResult schema: `0.2` - -## Endpoints - -```text -GET / service metadata -GET /meta contracts, profiles, codenames, capabilities -GET /health source and Ollama health -POST /ask Judge with optional automatic handoff -POST /tactician/ask explicit Tactician request -GET /conversations local history -GET /conversations/{id} conversation detail -PATCH /conversations/{id} rename conversation -DELETE /conversations/{id} delete conversation -``` - -## Ask request - -```json -{ - "question": "And does it combo with Ghave and Ashnod's Altar?", - "session_id": "optional-session-id", - "auto_handoff": true -} -``` - -`auto_handoff` defaults to `true`. Set it to `false` only for diagnostics that need the raw `strategy_required` Judge boundary. - -## Strategic response fields - -A handoff response remains compatible with the Judge evidence fields and may add: - -```json -{ - "authority": "tactician", - "strategy_intent": "combo_detection", - "combo_classification": "infinite_combo", - "combo_steps": [], - "outcomes": [], - "synergies": [], - "risks": [], - "inherited_cards": ["Young Wolf"], - "judge_queries": [], - "judge_result": {} -} -``` - - -## Release metadata - -`GET /meta` and `GET /health` expose the public version, PEP 440 package version, release channel, codename, and canonical Git tag. The current public identity is `v0.1.1-beta` — **Force of Will**. +# MagicAI HTTP contract + +## Versions + +- API contract: `1.2` +- JudgeResult schema: `1.0` +- TacticianResult schema: `0.2` + +## Endpoints + +```text +GET / service metadata +GET /meta contracts, profiles, codenames, capabilities +GET /health source and Ollama health +POST /ask Judge with optional automatic handoff +POST /tactician/ask explicit Tactician request +GET /conversations local history +GET /conversations/{id} conversation detail +PATCH /conversations/{id} rename conversation +DELETE /conversations/{id} delete conversation +``` + +## Ask request + +```json +{ + "question": "And does it combo with Ghave and Ashnod's Altar?", + "session_id": "optional-session-id", + "auto_handoff": true +} +``` + +`auto_handoff` defaults to `true`. Set it to `false` only for diagnostics that need the raw `strategy_required` Judge boundary. + +## Strategic response fields + +A handoff response remains compatible with the Judge evidence fields and may add: + +```json +{ + "authority": "tactician", + "strategy_intent": "combo_detection", + "combo_classification": "infinite_combo", + "combo_steps": [], + "outcomes": [], + "synergies": [], + "risks": [], + "inherited_cards": ["Young Wolf"], + "judge_queries": [], + "judge_result": {} +} +``` + + +## Release metadata + +`GET /meta` and `GET /health` expose the public version, PEP 440 package version, release channel, codename, and canonical Git tag. The current public identity is `v0.1.1-beta` — **Force of Will**. diff --git a/docs/BRANCHING.md b/docs/BRANCHING.md index 42f18d2..b68cec5 100644 --- a/docs/BRANCHING.md +++ b/docs/BRANCHING.md @@ -1,66 +1,66 @@ -# Branching policy - -MagicAI uses a simple release-oriented branching model. - -## Permanent branches - -### `main` - -`main` contains published releases and release candidates that are ready to be presented to users. Tags are created from `main`. - -### `develop` - -`develop` is the integration branch for the next development cycle and the preferred default branch while MagicAI remains under active beta development. - -## Short-lived branches - -Use a focused branch for each change: - -```text -feature/ -fix/ -chore/ -docs/ -test/ -``` - -Examples: - -```text -feature/sprint12-2-judge-tool-gateway -fix/undying-evidence-contract -chore/repository-health-foundation -``` - -Open pull requests against `develop` unless the change is an urgent release fix. - -## Release flow - -1. Merge completed feature, fix, test, and chore branches into `develop`. -2. Run the required CI and release validation on `develop`. -3. Open a release pull request from `develop` to `main`. -4. Merge the release pull request without rewriting public history. -5. Create the canonical release tag from `main`. -6. Merge any release-only corrections from `main` back into `develop`. - -## Hotfix flow - -Urgent corrections to a published release may branch from `main` using `fix/` or `hotfix/`. After release, merge the hotfix back into `develop`. - -## Branch cleanup - -Delete merged short-lived branches after their pull request is complete. Preserve important milestones through tags, releases, changelogs, and documented commits rather than permanent sprint or backup branches. - -Local safety branches may be created before risky operations, but they should not normally be pushed to the shared repository. Remove them after the operation has been verified. - -## Protection recommendations - -Protect `main` and `develop` with: - -- required pull requests; -- required CI checks; -- blocked force pushes; -- blocked deletion; -- resolved review conversations before merge. - -A sole maintainer may temporarily keep review requirements lightweight, but CI and history protection should remain enabled. +# Branching policy + +MagicAI uses a simple release-oriented branching model. + +## Permanent branches + +### `main` + +`main` contains published releases and release candidates that are ready to be presented to users. Tags are created from `main`. + +### `develop` + +`develop` is the integration branch for the next development cycle and the preferred default branch while MagicAI remains under active beta development. + +## Short-lived branches + +Use a focused branch for each change: + +```text +feature/ +fix/ +chore/ +docs/ +test/ +``` + +Examples: + +```text +feature/sprint12-2-judge-tool-gateway +fix/undying-evidence-contract +chore/repository-health-foundation +``` + +Open pull requests against `develop` unless the change is an urgent release fix. + +## Release flow + +1. Merge completed feature, fix, test, and chore branches into `develop`. +2. Run the required CI and release validation on `develop`. +3. Open a release pull request from `develop` to `main`. +4. Merge the release pull request without rewriting public history. +5. Create the canonical release tag from `main`. +6. Merge any release-only corrections from `main` back into `develop`. + +## Hotfix flow + +Urgent corrections to a published release may branch from `main` using `fix/` or `hotfix/`. After release, merge the hotfix back into `develop`. + +## Branch cleanup + +Delete merged short-lived branches after their pull request is complete. Preserve important milestones through tags, releases, changelogs, and documented commits rather than permanent sprint or backup branches. + +Local safety branches may be created before risky operations, but they should not normally be pushed to the shared repository. Remove them after the operation has been verified. + +## Protection recommendations + +Protect `main` and `develop` with: + +- required pull requests; +- required CI checks; +- blocked force pushes; +- blocked deletion; +- resolved review conversations before merge. + +A sole maintainer may temporarily keep review requirements lightweight, but CI and history protection should remain enabled. diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index d33b149..d1ce11f 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -1,104 +1,104 @@ -# MagicAI command reference - -## Environment - -```bash -cd ~/MagicAI -source .venv/bin/activate -export PYTHONPATH=. -``` - -## Start API and UI - -```bash -python -m uvicorn magicai.api:app --reload -``` - -## Source updates - -```bash -./scripts/download_sources.sh -./scripts/download_rules.sh -python scripts/update_scryfall_symbology.py -``` - -## Focused tests - -```bash -python -m tests.validation.rule_renderer_test -python -m tests.validation.oracle_renderer_test -python -m tests.validation.answer_validation_test -python -m tests.tactician.tactician_reviewer_test -python -m tests.tactician.tactician_strategy_test -python -m tests.tactician.tactician_conversation_handoff_test -python -m tests.api.tactician_api_contract_test -``` - -## Fast pull request checks - -```bash -python scripts/ci_check.py -``` - -## Release packages - -```bash -python scripts/package_release.py --source -python scripts/package_release.py --full -``` - -## GitHub repository analysis export - -```bash -./scripts/export_github_analysis.sh -``` - -## Dynamic Gauntlet - -```bash -python -m tests.quality.dynamic_gauntlet_test \ - --seed 184729 \ - --cases 42 -``` - -## Multi-seed campaign - -```bash -python -m tests.quality.dynamic_campaign_test \ - --base-seed 184729 \ - --runs 20 \ - --cases 50 \ - --workers 4 \ - --output-dir quality-results/dynamic-campaign \ - --require-full-coverage -``` - -Resume: - -```bash -python -m tests.quality.dynamic_campaign_test \ - --base-seed 184729 \ - --runs 20 \ - --cases 50 \ - --workers 4 \ - --output-dir quality-results/dynamic-campaign \ - --require-full-coverage \ - --resume -``` - -## Exhaustive Oracle audit - -```bash -python -u -m tests.quality.oracle_exhaustive_test \ - --workers 4 \ - --shard-size 250 \ - --output-dir quality-results/oracle-exhaustive -``` - -## Git checks - -```bash -git status --short -git diff --check -git diff --stat -``` +# MagicAI command reference + +## Environment + +```bash +cd ~/MagicAI +source .venv/bin/activate +export PYTHONPATH=. +``` + +## Start API and UI + +```bash +python -m uvicorn magicai.api:app --reload +``` + +## Source updates + +```bash +./scripts/download_sources.sh +./scripts/download_rules.sh +python scripts/update_scryfall_symbology.py +``` + +## Focused tests + +```bash +python -m tests.validation.rule_renderer_test +python -m tests.validation.oracle_renderer_test +python -m tests.validation.answer_validation_test +python -m tests.tactician.tactician_reviewer_test +python -m tests.tactician.tactician_strategy_test +python -m tests.tactician.tactician_conversation_handoff_test +python -m tests.api.tactician_api_contract_test +``` + +## Fast pull request checks + +```bash +python scripts/ci_check.py +``` + +## Release packages + +```bash +python scripts/package_release.py --source +python scripts/package_release.py --full +``` + +## GitHub repository analysis export + +```bash +./scripts/export_github_analysis.sh +``` + +## Dynamic Gauntlet + +```bash +python -m tests.quality.dynamic_gauntlet_test \ + --seed 184729 \ + --cases 42 +``` + +## Multi-seed campaign + +```bash +python -m tests.quality.dynamic_campaign_test \ + --base-seed 184729 \ + --runs 20 \ + --cases 50 \ + --workers 4 \ + --output-dir quality-results/dynamic-campaign \ + --require-full-coverage +``` + +Resume: + +```bash +python -m tests.quality.dynamic_campaign_test \ + --base-seed 184729 \ + --runs 20 \ + --cases 50 \ + --workers 4 \ + --output-dir quality-results/dynamic-campaign \ + --require-full-coverage \ + --resume +``` + +## Exhaustive Oracle audit + +```bash +python -u -m tests.quality.oracle_exhaustive_test \ + --workers 4 \ + --shard-size 250 \ + --output-dir quality-results/oracle-exhaustive +``` + +## Git checks + +```bash +git status --short +git diff --check +git diff --stat +``` diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index a834a65..b5b8c2b 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -1,40 +1,40 @@ -# Contributing to MagicAI - -## Principles - -- Keep the Judge as the sole factual authority. -- Keep strategic source access behind the Judge gateway. -- Prefer generic semantic fixes over card-name conditions. -- Add focused tests for every repaired family. -- Preserve source provenance and version information. -- Treat evaluation artifacts as evaluation only. - -## Development flow - -```text -main published or stable work -develop integrated development -feature/* isolated sprint branches -``` - -Before opening a pull request: - -```bash -python scripts/ci_check.py -git status --short -``` - -Run the focused modules related to the change and document anything that could not be tested in the local environment. - -Contributions are expected to use English for Markdown documentation and code-facing messages unless the output is intentionally localized for users. - - -## Branches and releases - -Open ordinary changes against `develop`. Published releases are promoted to `main`. See [BRANCHING.md](BRANCHING.md) and [RELEASE_PROCESS.md](RELEASE_PROCESS.md). - -## Pull requests - -Keep pull requests focused, explain source-authority impact, list the tests actually executed, and document limitations. New behavior should include a regression test. Generated sources, reports, logs, databases, analysis bundles, and private data must not be committed. - -All contributors must follow [../CODE_OF_CONDUCT.md](../CODE_OF_CONDUCT.md), [../SECURITY.md](../SECURITY.md), and [../SUPPORT.md](../SUPPORT.md). +# Contributing to MagicAI + +## Principles + +- Keep the Judge as the sole factual authority. +- Keep strategic source access behind the Judge gateway. +- Prefer generic semantic fixes over card-name conditions. +- Add focused tests for every repaired family. +- Preserve source provenance and version information. +- Treat evaluation artifacts as evaluation only. + +## Development flow + +```text +main published or stable work +develop integrated development +feature/* isolated sprint branches +``` + +Before opening a pull request: + +```bash +python scripts/ci_check.py +git status --short +``` + +Run the focused modules related to the change and document anything that could not be tested in the local environment. + +Contributions are expected to use English for Markdown documentation and code-facing messages unless the output is intentionally localized for users. + + +## Branches and releases + +Open ordinary changes against `develop`. Published releases are promoted to `main`. See [BRANCHING.md](BRANCHING.md) and [RELEASE_PROCESS.md](RELEASE_PROCESS.md). + +## Pull requests + +Keep pull requests focused, explain source-authority impact, list the tests actually executed, and document limitations. New behavior should include a regression test. Generated sources, reports, logs, databases, analysis bundles, and private data must not be committed. + +All contributors must follow [../CODE_OF_CONDUCT.md](../CODE_OF_CONDUCT.md), [../SECURITY.md](../SECURITY.md), and [../SUPPORT.md](../SUPPORT.md). diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index 354acd3..ac636e0 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -1,73 +1,73 @@ -# MagicAI quick start - -## Stable branch - -```bash -git clone https://github.com/Fartis/MagicAI.git -cd MagicAI -``` - -## Active development branch - -```bash -git clone -b develop https://github.com/Fartis/MagicAI.git -cd MagicAI -``` - -## Python environment - -Install `uv`: - -```bash -curl -LsSf https://astral.sh/uv/install.sh | sh -source "$HOME/.local/bin/env" - -## Local sources - -```bash -./scripts/download_sources.sh -./scripts/download_rules.sh -python scripts/update_scryfall_symbology.py -``` - -## Ollama on the same machine - -```bash -ollama pull qwen3:8b -export OLLAMA_URL=http://127.0.0.1:11434/api/chat -``` - -## Ollama in an existing container - -```bash -docker exec ollama ollama pull qwen3:8b -export OLLAMA_URL=http://127.0.0.1:11434/api/chat -``` - -## Ollama on another LAN machine - -```bash -export OLLAMA_URL=http://192.168.1.50:11434/api/chat -``` - -Use only a trusted local network. Do not expose an unprotected Ollama endpoint to the public Internet. - -## Start MagicAI - -```bash -python -m uvicorn magicai.api:app --reload -``` - -Open: - -```text -http://127.0.0.1:8000/ui -``` - -## Smoke test - -```bash -curl -X POST http://127.0.0.1:8000/ask \ - -H 'Content-Type: application/json' \ - -d '{"question":"What happens if I sacrifice Young Wolf?"}' -``` +# MagicAI quick start + +## Stable branch + +```bash +git clone https://github.com/Fartis/MagicAI.git +cd MagicAI +``` + +## Active development branch + +```bash +git clone -b develop https://github.com/Fartis/MagicAI.git +cd MagicAI +``` + +## Python environment + +Install `uv`: + +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +source "$HOME/.local/bin/env" + +## Local sources + +```bash +./scripts/download_sources.sh +./scripts/download_rules.sh +python scripts/update_scryfall_symbology.py +``` + +## Ollama on the same machine + +```bash +ollama pull qwen3:8b +export OLLAMA_URL=http://127.0.0.1:11434/api/chat +``` + +## Ollama in an existing container + +```bash +docker exec ollama ollama pull qwen3:8b +export OLLAMA_URL=http://127.0.0.1:11434/api/chat +``` + +## Ollama on another LAN machine + +```bash +export OLLAMA_URL=http://192.168.1.50:11434/api/chat +``` + +Use only a trusted local network. Do not expose an unprotected Ollama endpoint to the public Internet. + +## Start MagicAI + +```bash +python -m uvicorn magicai.api:app --reload +``` + +Open: + +```text +http://127.0.0.1:8000/ui +``` + +## Smoke test + +```bash +curl -X POST http://127.0.0.1:8000/ask \ + -H 'Content-Type: application/json' \ + -d '{"question":"What happens if I sacrifice Young Wolf?"}' +``` diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md index 3968466..1fe7632 100644 --- a/docs/RELEASE_PROCESS.md +++ b/docs/RELEASE_PROCESS.md @@ -1,75 +1,75 @@ -# Release process - -## Canonical release identity - -MagicAI separates its public release label from Python package metadata: - -```text -Public version: 0.1.1-beta -Git tag: v0.1.1-beta -Package version: 0.1.1b0 -Codename: Force of Will -``` - -Python package versions follow PEP 440. Public tags use lowercase prerelease identifiers consistently. - -## Pre-release checklist - -1. Confirm the release identity in `magicai/versioning.py` and `pyproject.toml`. -2. Update `README.md`, `docs/STATUS.md`, and `docs/ROADMAP.md`. -3. Run the focused CI suite: - - ```bash - python scripts/ci_check.py - ``` - -4. Run the release-specific regression and quality campaigns. -5. Confirm the working tree contains no databases, logs, backups, reports, large downloaded sources, or private files. -6. Build clean source and full packages from tracked files: - - ```bash - python scripts/package_release.py --source - python scripts/package_release.py --full - ``` - -7. Verify the generated SHA-256 files. -8. Open and merge the release pull request from `develop` to `main`. -9. Create the exact lowercase tag declared in `magicai/versioning.py`. -10. Publish release notes that include known limitations and validation performed. - -## Package types - -### Source package - -Contains tracked repository files required for development and installation. It excludes downloaded Scryfall bulk data and local runtime artifacts. - -### Full package - -Contains the same clean tracked repository snapshot and additionally includes the local Oracle and rulings bulk files when present. - -Both packages include a generated `INFO.txt` and `PACKAGE_MANIFEST.json`. - -## Tag policy - -Use consistent lowercase identifiers: - -```text -v0.1.0-alpha -v0.1.1-beta -v0.2.0-beta -v1.0.0 -``` - -Do not create variants such as `Alpha`, `Beta`, or differently formatted tags for the same release. - -## Release notes - -Release notes should describe: - -- user-visible changes; -- authority or source-boundary changes; -- migration steps; -- tests and campaigns executed; -- known limitations; -- package checksums; -- the release codename. +# Release process + +## Canonical release identity + +MagicAI separates its public release label from Python package metadata: + +```text +Public version: 0.1.1-beta +Git tag: v0.1.1-beta +Package version: 0.1.1b0 +Codename: Force of Will +``` + +Python package versions follow PEP 440. Public tags use lowercase prerelease identifiers consistently. + +## Pre-release checklist + +1. Confirm the release identity in `magicai/versioning.py` and `pyproject.toml`. +2. Update `README.md`, `docs/STATUS.md`, and `docs/ROADMAP.md`. +3. Run the focused CI suite: + + ```bash + python scripts/ci_check.py + ``` + +4. Run the release-specific regression and quality campaigns. +5. Confirm the working tree contains no databases, logs, backups, reports, large downloaded sources, or private files. +6. Build clean source and full packages from tracked files: + + ```bash + python scripts/package_release.py --source + python scripts/package_release.py --full + ``` + +7. Verify the generated SHA-256 files. +8. Open and merge the release pull request from `develop` to `main`. +9. Create the exact lowercase tag declared in `magicai/versioning.py`. +10. Publish release notes that include known limitations and validation performed. + +## Package types + +### Source package + +Contains tracked repository files required for development and installation. It excludes downloaded Scryfall bulk data and local runtime artifacts. + +### Full package + +Contains the same clean tracked repository snapshot and additionally includes the local Oracle and rulings bulk files when present. + +Both packages include a generated `INFO.txt` and `PACKAGE_MANIFEST.json`. + +## Tag policy + +Use consistent lowercase identifiers: + +```text +v0.1.0-alpha +v0.1.1-beta +v0.2.0-beta +v1.0.0 +``` + +Do not create variants such as `Alpha`, `Beta`, or differently formatted tags for the same release. + +## Release notes + +Release notes should describe: + +- user-visible changes; +- authority or source-boundary changes; +- migration steps; +- tests and campaigns executed; +- known limitations; +- package checksums; +- the release codename. diff --git a/docs/REPOSITORY_HEALTH.md b/docs/REPOSITORY_HEALTH.md index 0ccd573..abfa7c1 100644 --- a/docs/REPOSITORY_HEALTH.md +++ b/docs/REPOSITORY_HEALTH.md @@ -1,51 +1,51 @@ -# Repository health and community readiness - -MagicAI is intended to remain maintainable even when development is no longer concentrated in one person's hands. Repository health therefore advances alongside application features. - -## Current priorities - -### Critical foundation - -- fast GitHub Actions checks for pull requests; -- explicit `main` and `develop` responsibilities; -- clean and reproducible release packages; -- consistent version and tag naming; -- pull request, security, support, and conduct policies; -- automated dependency update proposals; -- removal of generated local artifacts from source exports. - -### Maintainability - -- test categories for unit, integration, quality, slow, Ollama, and network-dependent checks; -- centralized tooling configuration; -- gradual static analysis and typing improvements; -- documented source-authority contracts; -- documented development and release procedures; -- cleanup of merged sprint and temporary backup branches. - -### Community continuity - -- maintainership and governance documents; -- architecture decision records; -- component ownership when multiple maintainers exist; -- newcomer-friendly issues and contribution paths; -- release procedures that another maintainer can execute safely. - -## CI philosophy - -Pull request CI must be fast, local-source independent, and deterministic. Large Oracle campaigns, network integrations, and Ollama-backed evaluations remain separate manual or scheduled workflows. - -A passing CI result means the selected contracts and regressions passed. It does not claim complete Magic rules coverage. - -## Community ownership - -Contributors should be able to determine: - -- where factual authority resides; -- how the Strategist accesses sources; -- how to add a Judge tool; -- how to convert a failure into a regression; -- how to package and release the application; -- how to disclose security concerns safely. - -The project should preserve these decisions in documentation and tests rather than relying on maintainer memory. +# Repository health and community readiness + +MagicAI is intended to remain maintainable even when development is no longer concentrated in one person's hands. Repository health therefore advances alongside application features. + +## Current priorities + +### Critical foundation + +- fast GitHub Actions checks for pull requests; +- explicit `main` and `develop` responsibilities; +- clean and reproducible release packages; +- consistent version and tag naming; +- pull request, security, support, and conduct policies; +- automated dependency update proposals; +- removal of generated local artifacts from source exports. + +### Maintainability + +- test categories for unit, integration, quality, slow, Ollama, and network-dependent checks; +- centralized tooling configuration; +- gradual static analysis and typing improvements; +- documented source-authority contracts; +- documented development and release procedures; +- cleanup of merged sprint and temporary backup branches. + +### Community continuity + +- maintainership and governance documents; +- architecture decision records; +- component ownership when multiple maintainers exist; +- newcomer-friendly issues and contribution paths; +- release procedures that another maintainer can execute safely. + +## CI philosophy + +Pull request CI must be fast, local-source independent, and deterministic. Large Oracle campaigns, network integrations, and Ollama-backed evaluations remain separate manual or scheduled workflows. + +A passing CI result means the selected contracts and regressions passed. It does not claim complete Magic rules coverage. + +## Community ownership + +Contributors should be able to determine: + +- where factual authority resides; +- how the Strategist accesses sources; +- how to add a Judge tool; +- how to convert a failure into a regression; +- how to package and release the application; +- how to disclose security concerns safely. + +The project should preserve these decisions in documentation and tests rather than relying on maintainer memory. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 959e17e..1663e75 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,125 +1,125 @@ -# MagicAI roadmap - -## Release targets - -### 0.1.1 beta — Force of Will — current - -The first public beta establishes the integrated Judge and Tactician foundation, persistent conversations, structured evidence, automated handoff, and repository-health basics. - -### 0.2.0 beta — Ponder - -The next integrated beta requires: - -- reliable Judge answers for the covered families; -- source-grounded validation and safe failure; -- automatic Judge-to-Tactician handoff; -- resilient conversation continuity; -- iterative Tactician queries through the Judge tool gateway; -- formal combo reconstruction; -- useful local UI and persistent history; -- reproducible installation and evaluation. - -### 1.0 — NicolAI Bolas - -The first major release should add mature deck analysis, authorized strategic sources, collection awareness, broader format support, and production-grade packaging. - -## Repository health — parallel track - -- Fast pull request CI without Ollama or bulk sources. -- Clean tracked-file release packaging. -- Security, support, conduct, and pull request policies. -- Dependabot configuration. -- Explicit branch and release procedures. -- Later: test markers, static analysis, governance, maintainers, ADRs, and branch cleanup. - -## Sprint 12 — Tactician integration - -### 12.0 — Initial vertical slice — complete - -- Tactician profile and API endpoint. -- Judge contradiction review. -- Basic role and synergy analysis. - -### 12.1 — Automatic handoff and continuity — implemented - -- Automatic handoff from `/ask`. -- No duplicate conversation turns. -- Referential previous-card inheritance. -- Intent-specific combo boundary. -- Structured combo steps and outcomes. -- Generic Young Wolf + mana outlet + counter converter loop recognition. -- Judge capability registry. - -### 12.2 — Judge tool gateway - -- Typed tool requests and responses. -- Per-tool provenance, version, latency, and authority. -- Legality exposed to Tactician evidence. -- Query cache and budgets. -- Capability-missing responses. - -### 12.3 — Autonomous investigation planner - -- Hypothesis decomposition. -- Multiple Judge queries per user request. -- Evidence sufficiency scoring. -- Alternative and counterexample search. -- Bounded time and query budgets. -- Full investigation trace. - -### 12.4 — Formal combo verifier - -- State graph. -- Costs and resources. -- Zones, targets, timing, and priority. -- State restoration. -- Net output. -- Interruption points. -- Infinite, bounded, synergy, and non-combo classifications. - -### 12.5 — Commander Spellbook connector - -- Official or permitted API client. -- Local cache and provenance. -- Candidate retrieval by cards or deck. -- Mandatory Oracle and rules revalidation. -- Drift detection. - -### 12.6 — Deck analysis - -- Curve, lands, and color sources. -- Ramp, card advantage, interaction, and protection. -- Engines, win conditions, and redundancy. -- Existing and near-complete combos. -- Budget and collection-aware alternatives. - -### 12.7 — Authorized strategic statistics - -- Provider abstraction. -- Authorized EDHREC integration or explicit user import. -- Popularity and synergy statistics clearly labeled as non-authoritative. - -### 12.8 — User context - -- Format and Commander bracket. -- Budget. -- Collection. -- Preferred play style. -- Local metagame. -- Infinite-combo preferences. - -### 12.9 — Critic integration - -- Structured challenges. -- Evidence contradiction checks. -- Second validation. -- Source-built fallback. - -### 12.10 — Strategic Gauntlets - -- Handoff Gauntlet. -- Conversation Reference Gauntlet. -- Combo Verification Gauntlet. -- Synergy Classification Gauntlet. -- Spellbook Drift Gauntlet. -- Judge–Tactician Disagreement Gauntlet. +# MagicAI roadmap + +## Release targets + +### 0.1.1 beta — Force of Will — current + +The first public beta establishes the integrated Judge and Tactician foundation, persistent conversations, structured evidence, automated handoff, and repository-health basics. + +### 0.2.0 beta — Ponder + +The next integrated beta requires: + +- reliable Judge answers for the covered families; +- source-grounded validation and safe failure; +- automatic Judge-to-Tactician handoff; +- resilient conversation continuity; +- iterative Tactician queries through the Judge tool gateway; +- formal combo reconstruction; +- useful local UI and persistent history; +- reproducible installation and evaluation. + +### 1.0 — NicolAI Bolas + +The first major release should add mature deck analysis, authorized strategic sources, collection awareness, broader format support, and production-grade packaging. + +## Repository health — parallel track + +- Fast pull request CI without Ollama or bulk sources. +- Clean tracked-file release packaging. +- Security, support, conduct, and pull request policies. +- Dependabot configuration. +- Explicit branch and release procedures. +- Later: test markers, static analysis, governance, maintainers, ADRs, and branch cleanup. + +## Sprint 12 — Tactician integration + +### 12.0 — Initial vertical slice — complete + +- Tactician profile and API endpoint. +- Judge contradiction review. +- Basic role and synergy analysis. + +### 12.1 — Automatic handoff and continuity — implemented + +- Automatic handoff from `/ask`. +- No duplicate conversation turns. +- Referential previous-card inheritance. +- Intent-specific combo boundary. +- Structured combo steps and outcomes. +- Generic Young Wolf + mana outlet + counter converter loop recognition. +- Judge capability registry. + +### 12.2 — Judge tool gateway + +- Typed tool requests and responses. +- Per-tool provenance, version, latency, and authority. +- Legality exposed to Tactician evidence. +- Query cache and budgets. +- Capability-missing responses. + +### 12.3 — Autonomous investigation planner + +- Hypothesis decomposition. +- Multiple Judge queries per user request. +- Evidence sufficiency scoring. +- Alternative and counterexample search. +- Bounded time and query budgets. +- Full investigation trace. + +### 12.4 — Formal combo verifier + +- State graph. +- Costs and resources. +- Zones, targets, timing, and priority. +- State restoration. +- Net output. +- Interruption points. +- Infinite, bounded, synergy, and non-combo classifications. + +### 12.5 — Commander Spellbook connector + +- Official or permitted API client. +- Local cache and provenance. +- Candidate retrieval by cards or deck. +- Mandatory Oracle and rules revalidation. +- Drift detection. + +### 12.6 — Deck analysis + +- Curve, lands, and color sources. +- Ramp, card advantage, interaction, and protection. +- Engines, win conditions, and redundancy. +- Existing and near-complete combos. +- Budget and collection-aware alternatives. + +### 12.7 — Authorized strategic statistics + +- Provider abstraction. +- Authorized EDHREC integration or explicit user import. +- Popularity and synergy statistics clearly labeled as non-authoritative. + +### 12.8 — User context + +- Format and Commander bracket. +- Budget. +- Collection. +- Preferred play style. +- Local metagame. +- Infinite-combo preferences. + +### 12.9 — Critic integration + +- Structured challenges. +- Evidence contradiction checks. +- Second validation. +- Source-built fallback. + +### 12.10 — Strategic Gauntlets + +- Handoff Gauntlet. +- Conversation Reference Gauntlet. +- Combo Verification Gauntlet. +- Synergy Classification Gauntlet. +- Spellbook Drift Gauntlet. +- Judge–Tactician Disagreement Gauntlet. diff --git a/docs/STATUS.md b/docs/STATUS.md index 58934a6..9d9a2ba 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,52 +1,52 @@ -# Current MagicAI status - -> Current release: `v0.1.1-beta` — **Force of Will** -> Next beta milestone: `v0.2.0-beta` — **Ponder** -> Planned 1.0 codename: **NicolAI Bolas** - -## Overall assessment - -MagicAI is an early public beta with an advanced Judge core and an integrated Tactician foundation. - -Approximate maturity relative to the complete vision: - -| Area | Maturity | -|---|---:| -| Local Oracle, rules, rulings, and source health | 85% | -| Evidence retrieval and provenance | 75% | -| Deterministic Judge coverage | 70% | -| LLM validation and repair | 50% | -| Evaluation infrastructure | 80% | -| Local UI and persistent history | 65% | -| Tactician handoff and continuity | 35% | -| Formal combo engine | 20% | -| Full deck analysis | 5% | -| Commander Spellbook | 0% | -| Authorized statistics | 0% | - -These are planning estimates, not coverage statistics. - -## Current strengths - -- The model is not the sole authority. -- Sources are local and versioned. -- Deterministic routes avoid unnecessary LLM calls. -- Evaluation campaigns are reproducible and resumable. -- Human audit can expose false positives after harness PASS results. -- Problems are fixed by semantic families rather than card-name exceptions. -- Strategic questions can now hand off automatically to the Tactician. -- Follow-up card context can survive the handoff. - -## Current blockers before Ponder - -- Multi-query autonomous Tactician planning is not implemented yet. -- The Judge tool gateway is a registry, not yet a complete typed execution API. -- Arbitrary combos are not formally proven through a general state graph. -- Validation can still miss unsupported LLM interpretations outside covered contracts. -- Spellbook, authorized statistics, user collection, and metagame sources are not connected. -- The full exhaustive evaluation must be run on the user's local Oracle snapshot after each major semantic change. - - -## Repository health - -The first repository-health foundation adds fast pull request CI, clean release packaging, community policies, dependency update configuration, and explicit branching and release documentation. Large Oracle and Ollama campaigns remain separate from fast CI. +# Current MagicAI status + +> Current release: `v0.1.1-beta` — **Force of Will** +> Next beta milestone: `v0.2.0-beta` — **Ponder** +> Planned 1.0 codename: **NicolAI Bolas** + +## Overall assessment + +MagicAI is an early public beta with an advanced Judge core and an integrated Tactician foundation. + +Approximate maturity relative to the complete vision: + +| Area | Maturity | +|---|---:| +| Local Oracle, rules, rulings, and source health | 85% | +| Evidence retrieval and provenance | 75% | +| Deterministic Judge coverage | 70% | +| LLM validation and repair | 50% | +| Evaluation infrastructure | 80% | +| Local UI and persistent history | 65% | +| Tactician handoff and continuity | 35% | +| Formal combo engine | 20% | +| Full deck analysis | 5% | +| Commander Spellbook | 0% | +| Authorized statistics | 0% | + +These are planning estimates, not coverage statistics. + +## Current strengths + +- The model is not the sole authority. +- Sources are local and versioned. +- Deterministic routes avoid unnecessary LLM calls. +- Evaluation campaigns are reproducible and resumable. +- Human audit can expose false positives after harness PASS results. +- Problems are fixed by semantic families rather than card-name exceptions. +- Strategic questions can now hand off automatically to the Tactician. +- Follow-up card context can survive the handoff. + +## Current blockers before Ponder + +- Multi-query autonomous Tactician planning is not implemented yet. +- The Judge tool gateway is a registry, not yet a complete typed execution API. +- Arbitrary combos are not formally proven through a general state graph. +- Validation can still miss unsupported LLM interpretations outside covered contracts. +- Spellbook, authorized statistics, user collection, and metagame sources are not connected. +- The full exhaustive evaluation must be run on the user's local Oracle snapshot after each major semantic change. + + +## Repository health + +The first repository-health foundation adds fast pull request CI, clean release packaging, community policies, dependency update configuration, and explicit branching and release documentation. Large Oracle and Ollama campaigns remain separate from fast CI. diff --git a/magicai/api/health.py b/magicai/api/health.py index 8249cf1..acf154e 100644 --- a/magicai/api/health.py +++ b/magicai/api/health.py @@ -1,121 +1,121 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import Any -from urllib.parse import urlsplit, urlunsplit - -import requests - -from magicai.llm.ollama import MODEL, OLLAMA_URL -from magicai.sources.health import SourceHealth, get_source_health -from magicai.versioning import ( - API_CONTRACT_VERSION, - JUDGE_RESULT_SCHEMA_VERSION, - get_project_version, - get_package_version, - RELEASE_CHANNEL, - RELEASE_CODENAME, - RELEASE_TAG, -) - - -@dataclass(frozen=True, slots=True) -class ServiceProbe: - status: str - available: bool - detail: str = "" - model: str | None = None - - def to_dict(self) -> dict[str, Any]: - return { - "status": self.status, - "available": self.available, - "detail": self.detail, - "model": self.model, - } - - -def probe_ollama(timeout: float = 2.0) -> ServiceProbe: - tags_url = _ollama_tags_url(OLLAMA_URL) - - try: - response = requests.get(tags_url, timeout=timeout) - response.raise_for_status() - payload = response.json() - except (requests.RequestException, ValueError) as error: - return ServiceProbe( - status="unavailable", - available=False, - detail=f"Ollama did not respond successfully: {error.__class__.__name__}.", - model=MODEL, - ) - - models = { - str(item.get("name", "")) - for item in payload.get("models", []) - if isinstance(item, dict) - } - model_available = ( - MODEL in models - or any(name.split(":", 1)[0] == MODEL.split(":", 1)[0] for name in models) - ) - - return ServiceProbe( - status="available" if model_available else "degraded", - available=model_available, - detail=( - "Ollama and the configured model are available." - if model_available - else "Ollama is reachable, but the configured model was not listed." - ), - model=MODEL, - ) - - -def build_health_payload( - *, - source_health: SourceHealth | None = None, - ollama_probe: ServiceProbe | None = None, -) -> dict[str, Any]: - sources = source_health or get_source_health() - ollama = ollama_probe or probe_ollama() - - ready = sources.ready - full_service = ready and ollama.available - - if not ready: - status = "unavailable" - elif full_service and sources.complete: - status = "ok" - else: - status = "degraded" - - return { - "status": status, - "ready": ready, - "full_service": full_service, - "project_version": get_project_version(), - "package_version": get_package_version(), - "release_channel": RELEASE_CHANNEL, - "release_codename": RELEASE_CODENAME, - "release_tag": RELEASE_TAG, - "api_contract_version": API_CONTRACT_VERSION, - "judge_result_schema_version": JUDGE_RESULT_SCHEMA_VERSION, - "sources": sources.to_dict(), - "services": { - "ollama": ollama.to_dict(), - }, - } - - -def _ollama_tags_url(chat_url: str) -> str: - parsed = urlsplit(chat_url) - return urlunsplit( - ( - parsed.scheme, - parsed.netloc, - "/api/tags", - "", - "", - ) - ) +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any +from urllib.parse import urlsplit, urlunsplit + +import requests + +from magicai.llm.ollama import MODEL, OLLAMA_URL +from magicai.sources.health import SourceHealth, get_source_health +from magicai.versioning import ( + API_CONTRACT_VERSION, + JUDGE_RESULT_SCHEMA_VERSION, + get_project_version, + get_package_version, + RELEASE_CHANNEL, + RELEASE_CODENAME, + RELEASE_TAG, +) + + +@dataclass(frozen=True, slots=True) +class ServiceProbe: + status: str + available: bool + detail: str = "" + model: str | None = None + + def to_dict(self) -> dict[str, Any]: + return { + "status": self.status, + "available": self.available, + "detail": self.detail, + "model": self.model, + } + + +def probe_ollama(timeout: float = 2.0) -> ServiceProbe: + tags_url = _ollama_tags_url(OLLAMA_URL) + + try: + response = requests.get(tags_url, timeout=timeout) + response.raise_for_status() + payload = response.json() + except (requests.RequestException, ValueError) as error: + return ServiceProbe( + status="unavailable", + available=False, + detail=f"Ollama did not respond successfully: {error.__class__.__name__}.", + model=MODEL, + ) + + models = { + str(item.get("name", "")) + for item in payload.get("models", []) + if isinstance(item, dict) + } + model_available = ( + MODEL in models + or any(name.split(":", 1)[0] == MODEL.split(":", 1)[0] for name in models) + ) + + return ServiceProbe( + status="available" if model_available else "degraded", + available=model_available, + detail=( + "Ollama and the configured model are available." + if model_available + else "Ollama is reachable, but the configured model was not listed." + ), + model=MODEL, + ) + + +def build_health_payload( + *, + source_health: SourceHealth | None = None, + ollama_probe: ServiceProbe | None = None, +) -> dict[str, Any]: + sources = source_health or get_source_health() + ollama = ollama_probe or probe_ollama() + + ready = sources.ready + full_service = ready and ollama.available + + if not ready: + status = "unavailable" + elif full_service and sources.complete: + status = "ok" + else: + status = "degraded" + + return { + "status": status, + "ready": ready, + "full_service": full_service, + "project_version": get_project_version(), + "package_version": get_package_version(), + "release_channel": RELEASE_CHANNEL, + "release_codename": RELEASE_CODENAME, + "release_tag": RELEASE_TAG, + "api_contract_version": API_CONTRACT_VERSION, + "judge_result_schema_version": JUDGE_RESULT_SCHEMA_VERSION, + "sources": sources.to_dict(), + "services": { + "ollama": ollama.to_dict(), + }, + } + + +def _ollama_tags_url(chat_url: str) -> str: + parsed = urlsplit(chat_url) + return urlunsplit( + ( + parsed.scheme, + parsed.netloc, + "/api/tags", + "", + "", + ) + ) diff --git a/magicai/api/routes.py b/magicai/api/routes.py index 5a0dc1c..3dbee91 100644 --- a/magicai/api/routes.py +++ b/magicai/api/routes.py @@ -1,253 +1,253 @@ -import sqlite3 - -from fastapi import APIRouter, HTTPException, Query - -from magicai.assistant import MagicAI -from magicai.api.health import build_health_payload -from magicai.api.schemas import ( - AskRequest, - AskResponse, - ConversationDeleteResponse, - ConversationDetailResponse, - ConversationRenameRequest, - ConversationSummaryResponse, - HealthResponse, - MetaResponse, - TacticianAskResponse, -) -from magicai.conversation.manager import ConversationManager -from magicai.tactician.core import Tactician, replace_boundary_answer -from magicai.judge_tools import get_capability_registry_payload -from magicai.judge_result import ( - JudgeConfidence, - JudgeOrigin, - JudgeStatus, -) -from magicai.versioning import ( - API_CONTRACT_VERSION, - JUDGE_RESULT_SCHEMA_VERSION, - get_project_version, - get_package_version, - TACTICIAN_RESULT_SCHEMA_VERSION, - RELEASE_CHANNEL, - RELEASE_CODENAME, - RELEASE_TAG, - NEXT_BETA_VERSION, - NEXT_BETA_CODENAME, - V1_CODENAME, -) - - -router = APIRouter() - -assistant = MagicAI() -tactician = Tactician(judge=assistant) -conversation_manager = ConversationManager() - - -@router.get("/") -def root(): - return { - "status": "ok", - "project": "MagicAI", - "project_version": get_project_version(), - "package_version": get_package_version(), - "release_channel": RELEASE_CHANNEL, - "release_codename": RELEASE_CODENAME, - "release_tag": RELEASE_TAG, - "api_contract_version": API_CONTRACT_VERSION, - "judge_result_schema_version": JUDGE_RESULT_SCHEMA_VERSION, - "ui": "/ui", - } - - -@router.get("/meta", response_model=MetaResponse) -def meta(): - return { - "project": "MagicAI", - "project_version": get_project_version(), - "package_version": get_package_version(), - "release_channel": RELEASE_CHANNEL, - "release_codename": RELEASE_CODENAME, - "release_tag": RELEASE_TAG, - "api_contract_version": API_CONTRACT_VERSION, - "judge_result_schema_version": JUDGE_RESULT_SCHEMA_VERSION, - "authority": "judge", - "judge_statuses": [item.value for item in JudgeStatus], - "judge_origins": [item.value for item in JudgeOrigin], - "confidence_levels": [item.value for item in JudgeConfidence], - "profiles": ["judge", "tactician"], - "tactician_result_schema_version": TACTICIAN_RESULT_SCHEMA_VERSION, - "next_beta_version": NEXT_BETA_VERSION, - "next_beta_codename": NEXT_BETA_CODENAME, - "v1_codename": V1_CODENAME, - "judge_capabilities": get_capability_registry_payload(), - } - - -@router.get("/health", response_model=HealthResponse) -def health(): - return build_health_payload() - - -@router.post("/ask", response_model=AskResponse) -def ask(request: AskRequest): - session_id, conversation = conversation_manager.get_or_create( - request.session_id - ) - - prior_cards = list(conversation.active_cards) - result = assistant.ask_result( - conversation, - request.question, - ) - - if request.auto_handoff and result.status is JudgeStatus.STRATEGY_REQUIRED: - result = tactician.from_judge_result( - question=request.question, - judge_result=result, - prior_cards=prior_cards, - ) - replace_boundary_answer(conversation, result) - - payload = result.to_dict() - try: - conversation_manager.save( - session_id, - conversation, - last_result={ - "session_id": session_id, - **payload, - }, - ) - except (OSError, sqlite3.Error): - payload["warnings"] = [ - *payload.get("warnings", []), - "La respuesta es válida, pero no se pudo guardar esta conversación en el historial local.", - ] - - return AskResponse( - session_id=session_id, - **payload, - ) - - - - -@router.post("/tactician/ask", response_model=TacticianAskResponse) -def tactician_ask(request: AskRequest): - session_id, conversation = conversation_manager.get_or_create( - request.session_id - ) - - result = tactician.ask_result( - conversation, - request.question, - ) - payload = result.to_dict() - - try: - conversation_manager.save( - session_id, - conversation, - last_result={ - "session_id": session_id, - **payload, - }, - ) - except (OSError, sqlite3.Error): - payload["warnings"] = [ - *payload.get("warnings", []), - "La respuesta es válida, pero no se pudo guardar esta conversación en el historial local.", - ] - - return TacticianAskResponse( - session_id=session_id, - **payload, - ) - - -@router.get( - "/conversations", - response_model=list[ConversationSummaryResponse], -) -def list_conversations( - limit: int = Query(default=50, ge=1, le=200), -): - return [ - _summary_response(summary) - for summary in conversation_manager.list(limit=limit) - ] - - -@router.get( - "/conversations/{session_id}", - response_model=ConversationDetailResponse, -) -def get_conversation(session_id: str): - record = conversation_manager.load(session_id) - if not record: - raise _conversation_not_found() - - summary = record.summary - return ConversationDetailResponse( - session_id=summary.session_id, - title=summary.title, - created_at=summary.created_at, - updated_at=summary.updated_at, - message_count=summary.message_count, - messages=[ - {"role": message.role, "content": message.content} - for message in record.conversation.history - ], - last_result=record.last_result, - ) - - -@router.patch( - "/conversations/{session_id}", - response_model=ConversationSummaryResponse, -) -def rename_conversation( - session_id: str, - request: ConversationRenameRequest, -): - summary = conversation_manager.rename(session_id, request.title) - if not summary: - raise _conversation_not_found() - return _summary_response(summary) - - -@router.delete( - "/conversations/{session_id}", - response_model=ConversationDeleteResponse, -) -def delete_conversation(session_id: str): - deleted = conversation_manager.delete(session_id) - if not deleted: - raise _conversation_not_found() - return ConversationDeleteResponse( - session_id=session_id, - deleted=True, - ) - - -def _summary_response(summary) -> ConversationSummaryResponse: - return ConversationSummaryResponse( - session_id=summary.session_id, - title=summary.title, - created_at=summary.created_at, - updated_at=summary.updated_at, - message_count=summary.message_count, - ) - - -def _conversation_not_found() -> HTTPException: - return HTTPException( - status_code=404, - detail={ - "code": "conversation_not_found", - "message": "The requested local conversation does not exist.", - "retryable": False, - }, - ) +import sqlite3 + +from fastapi import APIRouter, HTTPException, Query + +from magicai.assistant import MagicAI +from magicai.api.health import build_health_payload +from magicai.api.schemas import ( + AskRequest, + AskResponse, + ConversationDeleteResponse, + ConversationDetailResponse, + ConversationRenameRequest, + ConversationSummaryResponse, + HealthResponse, + MetaResponse, + TacticianAskResponse, +) +from magicai.conversation.manager import ConversationManager +from magicai.tactician.core import Tactician, replace_boundary_answer +from magicai.judge_tools import get_capability_registry_payload +from magicai.judge_result import ( + JudgeConfidence, + JudgeOrigin, + JudgeStatus, +) +from magicai.versioning import ( + API_CONTRACT_VERSION, + JUDGE_RESULT_SCHEMA_VERSION, + get_project_version, + get_package_version, + TACTICIAN_RESULT_SCHEMA_VERSION, + RELEASE_CHANNEL, + RELEASE_CODENAME, + RELEASE_TAG, + NEXT_BETA_VERSION, + NEXT_BETA_CODENAME, + V1_CODENAME, +) + + +router = APIRouter() + +assistant = MagicAI() +tactician = Tactician(judge=assistant) +conversation_manager = ConversationManager() + + +@router.get("/") +def root(): + return { + "status": "ok", + "project": "MagicAI", + "project_version": get_project_version(), + "package_version": get_package_version(), + "release_channel": RELEASE_CHANNEL, + "release_codename": RELEASE_CODENAME, + "release_tag": RELEASE_TAG, + "api_contract_version": API_CONTRACT_VERSION, + "judge_result_schema_version": JUDGE_RESULT_SCHEMA_VERSION, + "ui": "/ui", + } + + +@router.get("/meta", response_model=MetaResponse) +def meta(): + return { + "project": "MagicAI", + "project_version": get_project_version(), + "package_version": get_package_version(), + "release_channel": RELEASE_CHANNEL, + "release_codename": RELEASE_CODENAME, + "release_tag": RELEASE_TAG, + "api_contract_version": API_CONTRACT_VERSION, + "judge_result_schema_version": JUDGE_RESULT_SCHEMA_VERSION, + "authority": "judge", + "judge_statuses": [item.value for item in JudgeStatus], + "judge_origins": [item.value for item in JudgeOrigin], + "confidence_levels": [item.value for item in JudgeConfidence], + "profiles": ["judge", "tactician"], + "tactician_result_schema_version": TACTICIAN_RESULT_SCHEMA_VERSION, + "next_beta_version": NEXT_BETA_VERSION, + "next_beta_codename": NEXT_BETA_CODENAME, + "v1_codename": V1_CODENAME, + "judge_capabilities": get_capability_registry_payload(), + } + + +@router.get("/health", response_model=HealthResponse) +def health(): + return build_health_payload() + + +@router.post("/ask", response_model=AskResponse) +def ask(request: AskRequest): + session_id, conversation = conversation_manager.get_or_create( + request.session_id + ) + + prior_cards = list(conversation.active_cards) + result = assistant.ask_result( + conversation, + request.question, + ) + + if request.auto_handoff and result.status is JudgeStatus.STRATEGY_REQUIRED: + result = tactician.from_judge_result( + question=request.question, + judge_result=result, + prior_cards=prior_cards, + ) + replace_boundary_answer(conversation, result) + + payload = result.to_dict() + try: + conversation_manager.save( + session_id, + conversation, + last_result={ + "session_id": session_id, + **payload, + }, + ) + except (OSError, sqlite3.Error): + payload["warnings"] = [ + *payload.get("warnings", []), + "La respuesta es válida, pero no se pudo guardar esta conversación en el historial local.", + ] + + return AskResponse( + session_id=session_id, + **payload, + ) + + + + +@router.post("/tactician/ask", response_model=TacticianAskResponse) +def tactician_ask(request: AskRequest): + session_id, conversation = conversation_manager.get_or_create( + request.session_id + ) + + result = tactician.ask_result( + conversation, + request.question, + ) + payload = result.to_dict() + + try: + conversation_manager.save( + session_id, + conversation, + last_result={ + "session_id": session_id, + **payload, + }, + ) + except (OSError, sqlite3.Error): + payload["warnings"] = [ + *payload.get("warnings", []), + "La respuesta es válida, pero no se pudo guardar esta conversación en el historial local.", + ] + + return TacticianAskResponse( + session_id=session_id, + **payload, + ) + + +@router.get( + "/conversations", + response_model=list[ConversationSummaryResponse], +) +def list_conversations( + limit: int = Query(default=50, ge=1, le=200), +): + return [ + _summary_response(summary) + for summary in conversation_manager.list(limit=limit) + ] + + +@router.get( + "/conversations/{session_id}", + response_model=ConversationDetailResponse, +) +def get_conversation(session_id: str): + record = conversation_manager.load(session_id) + if not record: + raise _conversation_not_found() + + summary = record.summary + return ConversationDetailResponse( + session_id=summary.session_id, + title=summary.title, + created_at=summary.created_at, + updated_at=summary.updated_at, + message_count=summary.message_count, + messages=[ + {"role": message.role, "content": message.content} + for message in record.conversation.history + ], + last_result=record.last_result, + ) + + +@router.patch( + "/conversations/{session_id}", + response_model=ConversationSummaryResponse, +) +def rename_conversation( + session_id: str, + request: ConversationRenameRequest, +): + summary = conversation_manager.rename(session_id, request.title) + if not summary: + raise _conversation_not_found() + return _summary_response(summary) + + +@router.delete( + "/conversations/{session_id}", + response_model=ConversationDeleteResponse, +) +def delete_conversation(session_id: str): + deleted = conversation_manager.delete(session_id) + if not deleted: + raise _conversation_not_found() + return ConversationDeleteResponse( + session_id=session_id, + deleted=True, + ) + + +def _summary_response(summary) -> ConversationSummaryResponse: + return ConversationSummaryResponse( + session_id=summary.session_id, + title=summary.title, + created_at=summary.created_at, + updated_at=summary.updated_at, + message_count=summary.message_count, + ) + + +def _conversation_not_found() -> HTTPException: + return HTTPException( + status_code=404, + detail={ + "code": "conversation_not_found", + "message": "The requested local conversation does not exist.", + "retryable": False, + }, + ) diff --git a/magicai/api/schemas.py b/magicai/api/schemas.py index b70787c..c331a3f 100644 --- a/magicai/api/schemas.py +++ b/magicai/api/schemas.py @@ -1,164 +1,164 @@ -from typing import Any - -from pydantic import BaseModel, Field, field_validator - - -class AskRequest(BaseModel): - question: str = Field(min_length=1, max_length=10000) - session_id: str | None = Field(default=None, max_length=128) - auto_handoff: bool = True - - @field_validator("question") - @classmethod - def normalize_question(cls, value: str) -> str: - normalized = value.strip() - if not normalized: - raise ValueError("question must not be empty") - return normalized - - -class CardEvidenceResponse(BaseModel): - name: str - mana_cost: str | None = None - type_line: str | None = None - oracle_text: str | None = None - scryfall_uri: str | None = None - - -class RuleEvidenceResponse(BaseModel): - number: str | None = None - title: str | None = None - - -class RulingEvidenceResponse(BaseModel): - card_name: str | None = None - oracle_id: str | None = None - source: str | None = None - published_at: str | None = None - comment: str | None = None - - -class AskResponse(BaseModel): - schema_version: str - answer: str - session_id: str - question: str - status: str - origin: str - confidence: str - authority: str = "judge" - intent: str = "" - cards: list[CardEvidenceResponse] = Field(default_factory=list) - rules: list[RuleEvidenceResponse] = Field(default_factory=list) - rulings: list[RulingEvidenceResponse] = Field(default_factory=list) - retrieval_queries: list[str] = Field(default_factory=list) - assumptions: list[str] = Field(default_factory=list) - warnings: list[str] = Field(default_factory=list) - source_versions: dict[str, str] = Field(default_factory=dict) - source_health: dict[str, Any] = Field(default_factory=dict) - validation_attempts: int = 0 - reviewed_by: list[str] = Field(default_factory=list) - review_challenges: list[dict[str, Any]] = Field(default_factory=list) - authority_trace: list[str] = Field(default_factory=list) - strategy_intent: str = "" - synergies: list[str] = Field(default_factory=list) - risks: list[str] = Field(default_factory=list) - combo_classification: str = "" - combo_steps: list[str] = Field(default_factory=list) - outcomes: list[str] = Field(default_factory=list) - inherited_cards: list[str] = Field(default_factory=list) - judge_queries: list[dict[str, Any]] = Field(default_factory=list) - judge_result: dict[str, Any] = Field(default_factory=dict) - - -class TacticianAskResponse(AskResponse): - authority: str = "tactician" - - -class MetaResponse(BaseModel): - project: str - project_version: str - package_version: str - release_channel: str - release_codename: str - release_tag: str - api_contract_version: str - judge_result_schema_version: str - authority: str - judge_statuses: list[str] - judge_origins: list[str] - confidence_levels: list[str] - profiles: list[str] = Field(default_factory=list) - tactician_result_schema_version: str = "" - next_beta_version: str = "" - next_beta_codename: str = "" - v1_codename: str = "" - judge_capabilities: list[dict[str, Any]] = Field(default_factory=list) - - -class HealthResponse(BaseModel): - status: str - ready: bool - full_service: bool - project_version: str - package_version: str - release_channel: str - release_codename: str - release_tag: str - api_contract_version: str - judge_result_schema_version: str - sources: dict[str, Any] - services: dict[str, Any] - - -class ErrorDetailResponse(BaseModel): - location: list[str] = Field(default_factory=list) - message: str = "" - type: str = "" - - -class ErrorBodyResponse(BaseModel): - code: str - message: str - retryable: bool - details: list[ErrorDetailResponse] = Field(default_factory=list) - - -class ErrorResponse(BaseModel): - schema_version: str - error: ErrorBodyResponse - - -class ConversationMessageResponse(BaseModel): - role: str - content: str - - -class ConversationSummaryResponse(BaseModel): - session_id: str - title: str - created_at: str - updated_at: str - message_count: int - - -class ConversationDetailResponse(ConversationSummaryResponse): - messages: list[ConversationMessageResponse] = Field(default_factory=list) - last_result: dict[str, Any] | None = None - - -class ConversationRenameRequest(BaseModel): - title: str = Field(min_length=1, max_length=120) - - @field_validator("title") - @classmethod - def normalize_title(cls, value: str) -> str: - normalized = " ".join(value.strip().split()) - if not normalized: - raise ValueError("title must not be empty") - return normalized - - -class ConversationDeleteResponse(BaseModel): - session_id: str - deleted: bool +from typing import Any + +from pydantic import BaseModel, Field, field_validator + + +class AskRequest(BaseModel): + question: str = Field(min_length=1, max_length=10000) + session_id: str | None = Field(default=None, max_length=128) + auto_handoff: bool = True + + @field_validator("question") + @classmethod + def normalize_question(cls, value: str) -> str: + normalized = value.strip() + if not normalized: + raise ValueError("question must not be empty") + return normalized + + +class CardEvidenceResponse(BaseModel): + name: str + mana_cost: str | None = None + type_line: str | None = None + oracle_text: str | None = None + scryfall_uri: str | None = None + + +class RuleEvidenceResponse(BaseModel): + number: str | None = None + title: str | None = None + + +class RulingEvidenceResponse(BaseModel): + card_name: str | None = None + oracle_id: str | None = None + source: str | None = None + published_at: str | None = None + comment: str | None = None + + +class AskResponse(BaseModel): + schema_version: str + answer: str + session_id: str + question: str + status: str + origin: str + confidence: str + authority: str = "judge" + intent: str = "" + cards: list[CardEvidenceResponse] = Field(default_factory=list) + rules: list[RuleEvidenceResponse] = Field(default_factory=list) + rulings: list[RulingEvidenceResponse] = Field(default_factory=list) + retrieval_queries: list[str] = Field(default_factory=list) + assumptions: list[str] = Field(default_factory=list) + warnings: list[str] = Field(default_factory=list) + source_versions: dict[str, str] = Field(default_factory=dict) + source_health: dict[str, Any] = Field(default_factory=dict) + validation_attempts: int = 0 + reviewed_by: list[str] = Field(default_factory=list) + review_challenges: list[dict[str, Any]] = Field(default_factory=list) + authority_trace: list[str] = Field(default_factory=list) + strategy_intent: str = "" + synergies: list[str] = Field(default_factory=list) + risks: list[str] = Field(default_factory=list) + combo_classification: str = "" + combo_steps: list[str] = Field(default_factory=list) + outcomes: list[str] = Field(default_factory=list) + inherited_cards: list[str] = Field(default_factory=list) + judge_queries: list[dict[str, Any]] = Field(default_factory=list) + judge_result: dict[str, Any] = Field(default_factory=dict) + + +class TacticianAskResponse(AskResponse): + authority: str = "tactician" + + +class MetaResponse(BaseModel): + project: str + project_version: str + package_version: str + release_channel: str + release_codename: str + release_tag: str + api_contract_version: str + judge_result_schema_version: str + authority: str + judge_statuses: list[str] + judge_origins: list[str] + confidence_levels: list[str] + profiles: list[str] = Field(default_factory=list) + tactician_result_schema_version: str = "" + next_beta_version: str = "" + next_beta_codename: str = "" + v1_codename: str = "" + judge_capabilities: list[dict[str, Any]] = Field(default_factory=list) + + +class HealthResponse(BaseModel): + status: str + ready: bool + full_service: bool + project_version: str + package_version: str + release_channel: str + release_codename: str + release_tag: str + api_contract_version: str + judge_result_schema_version: str + sources: dict[str, Any] + services: dict[str, Any] + + +class ErrorDetailResponse(BaseModel): + location: list[str] = Field(default_factory=list) + message: str = "" + type: str = "" + + +class ErrorBodyResponse(BaseModel): + code: str + message: str + retryable: bool + details: list[ErrorDetailResponse] = Field(default_factory=list) + + +class ErrorResponse(BaseModel): + schema_version: str + error: ErrorBodyResponse + + +class ConversationMessageResponse(BaseModel): + role: str + content: str + + +class ConversationSummaryResponse(BaseModel): + session_id: str + title: str + created_at: str + updated_at: str + message_count: int + + +class ConversationDetailResponse(ConversationSummaryResponse): + messages: list[ConversationMessageResponse] = Field(default_factory=list) + last_result: dict[str, Any] | None = None + + +class ConversationRenameRequest(BaseModel): + title: str = Field(min_length=1, max_length=120) + + @field_validator("title") + @classmethod + def normalize_title(cls, value: str) -> str: + normalized = " ".join(value.strip().split()) + if not normalized: + raise ValueError("title must not be empty") + return normalized + + +class ConversationDeleteResponse(BaseModel): + session_id: str + deleted: bool diff --git a/magicai/versioning.py b/magicai/versioning.py index c3d09f2..e07517c 100644 --- a/magicai/versioning.py +++ b/magicai/versioning.py @@ -1,40 +1,40 @@ -from __future__ import annotations - -JUDGE_RESULT_SCHEMA_VERSION = "1.0" -API_CONTRACT_VERSION = "1.3" -TACTICIAN_RESULT_SCHEMA_VERSION = "0.2" - -# Packaging metadata must follow PEP 440. Public release names remain SemVer-like. -PACKAGE_FALLBACK_VERSION = "0.1.1b0" -PUBLIC_VERSION = "0.1.1-beta" -RELEASE_TAG = "v0.1.1-beta" -RELEASE_CHANNEL = "beta" -RELEASE_CODENAME = "Force of Will" - -NEXT_BETA_VERSION = "0.2.0-beta" -NEXT_BETA_CODENAME = "Ponder" -V1_CODENAME = "NicolAI Bolas" - - -def get_package_version() -> str: - """Return the canonical PEP 440 package version.""" - - return PACKAGE_FALLBACK_VERSION - - -def get_project_version() -> str: - """Return the public release version used by the API and UI.""" - - return PUBLIC_VERSION - - -def get_release_identity() -> dict[str, str]: - """Return the canonical public release identity.""" - - return { - "version": PUBLIC_VERSION, - "tag": RELEASE_TAG, - "channel": RELEASE_CHANNEL, - "codename": RELEASE_CODENAME, - "package_version": get_package_version(), - } +from __future__ import annotations + +JUDGE_RESULT_SCHEMA_VERSION = "1.0" +API_CONTRACT_VERSION = "1.3" +TACTICIAN_RESULT_SCHEMA_VERSION = "0.2" + +# Packaging metadata must follow PEP 440. Public release names remain SemVer-like. +PACKAGE_FALLBACK_VERSION = "0.1.1b0" +PUBLIC_VERSION = "0.1.1-beta" +RELEASE_TAG = "v0.1.1-beta" +RELEASE_CHANNEL = "beta" +RELEASE_CODENAME = "Force of Will" + +NEXT_BETA_VERSION = "0.2.0-beta" +NEXT_BETA_CODENAME = "Ponder" +V1_CODENAME = "NicolAI Bolas" + + +def get_package_version() -> str: + """Return the canonical PEP 440 package version.""" + + return PACKAGE_FALLBACK_VERSION + + +def get_project_version() -> str: + """Return the public release version used by the API and UI.""" + + return PUBLIC_VERSION + + +def get_release_identity() -> dict[str, str]: + """Return the canonical public release identity.""" + + return { + "version": PUBLIC_VERSION, + "tag": RELEASE_TAG, + "channel": RELEASE_CHANNEL, + "codename": RELEASE_CODENAME, + "package_version": get_package_version(), + } diff --git a/pyproject.toml b/pyproject.toml index dbe92d8..52c4bfd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,24 +1,24 @@ -[build-system] -requires = ["setuptools>=83.0.0"] -build-backend = "setuptools.build_meta" - -[project] -name = "magicai" -version = "0.1.1b0" -description = "Local, source-grounded Magic: The Gathering Judge assistant" -readme = "README.md" -requires-python = ">=3.12" -license = "AGPL-3.0-or-later" -dependencies = [ - "fastapi>=0.139.0,<0.140.0", - "pydantic>=2.13.4,<3.0.0", - "requests>=2.34.2,<3.0.0", - "uvicorn>=0.50.2,<0.52.0", -] - -[tool.setuptools.packages.find] -include = ["magicai*"] -namespaces = true - -[tool.setuptools.package-data] -magicai = ["ui/static/*.html", "ui/static/*.css", "ui/static/*.js"] +[build-system] +requires = ["setuptools>=83.0.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "magicai" +version = "0.1.1b0" +description = "Local, source-grounded Magic: The Gathering Judge assistant" +readme = "README.md" +requires-python = ">=3.12" +license = "AGPL-3.0-or-later" +dependencies = [ + "fastapi>=0.139.0,<0.140.0", + "pydantic>=2.13.4,<3.0.0", + "requests>=2.34.2,<3.0.0", + "uvicorn>=0.50.2,<0.52.0", +] + +[tool.setuptools.packages.find] +include = ["magicai*"] +namespaces = true + +[tool.setuptools.package-data] +magicai = ["ui/static/*.html", "ui/static/*.css", "ui/static/*.js"] diff --git a/scripts/ci_check.py b/scripts/ci_check.py old mode 100755 new mode 100644 index 892b14e..67c013e --- a/scripts/ci_check.py +++ b/scripts/ci_check.py @@ -1,80 +1,80 @@ -#!/usr/bin/env python3 -"""Run MagicAI's fast, source-independent pull request checks.""" - -from __future__ import annotations - -import os -from pathlib import Path -import shutil -import subprocess -import sys -import tempfile - - -ROOT = Path(__file__).resolve().parents[1] - -TEST_MODULES = ( - "tests.repository.repository_health_test", - "tests.repository.release_packaging_test", - "tests.tactician.tactician_reviewer_test", - "tests.tactician.tactician_strategy_test", - "tests.tactician.tactician_conversation_handoff_test", - "tests.tactician.tactician_core_test", - "tests.api.api_contract_metadata_test", - "tests.api.tactician_api_contract_test", - "tests.api.tactician_auto_handoff_test", - "tests.api.judge_result_schema_test", - "tests.api.api_error_contract_test", - "tests.api.ui_routes_test", - "tests.conversation.conversation_repository_test", - "tests.validation.strategy_boundary_test", - "tests.validation.oracle_derived_undying_test", - "tests.validation.rule_renderer_test", - "tests.ui.ui_assets_test", - "tests.ui.ui_usability_test", -) - - -def run(command: list[str], *, environment: dict[str, str]) -> None: - print("+", " ".join(command), flush=True) - subprocess.run(command, cwd=ROOT, env=environment, check=True) - - -def main() -> int: - environment = os.environ.copy() - environment["PYTHONPATH"] = str(ROOT) - environment.setdefault("MAGICAI_QUIET_EVALUATION", "1") - - with tempfile.TemporaryDirectory(prefix="magicai-ci-") as directory: - environment.setdefault( - "MAGICAI_CONVERSATION_DB", - str(Path(directory) / "conversations.sqlite3"), - ) - - run( - [sys.executable, "-m", "compileall", "-q", "magicai", "tests", "scripts"], - environment=environment, - ) - - if (ROOT / ".git").exists(): - run(["git", "diff", "--check"], environment=environment) - - bash = shutil.which("bash") - if bash: - for script in sorted((ROOT / "scripts").glob("*.sh")): - run([bash, "-n", str(script.relative_to(ROOT))], environment=environment) - - node = shutil.which("node") - if node: - run([node, "--check", "magicai/ui/static/app.js"], environment=environment) - - for module in TEST_MODULES: - run([sys.executable, "-m", module], environment=environment) - - print() - print(f"MagicAI focused CI: {len(TEST_MODULES)}/{len(TEST_MODULES)} modules passed") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) +#!/usr/bin/env python3 +"""Run MagicAI's fast, source-independent pull request checks.""" + +from __future__ import annotations + +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile + + +ROOT = Path(__file__).resolve().parents[1] + +TEST_MODULES = ( + "tests.repository.repository_health_test", + "tests.repository.release_packaging_test", + "tests.tactician.tactician_reviewer_test", + "tests.tactician.tactician_strategy_test", + "tests.tactician.tactician_conversation_handoff_test", + "tests.tactician.tactician_core_test", + "tests.api.api_contract_metadata_test", + "tests.api.tactician_api_contract_test", + "tests.api.tactician_auto_handoff_test", + "tests.api.judge_result_schema_test", + "tests.api.api_error_contract_test", + "tests.api.ui_routes_test", + "tests.conversation.conversation_repository_test", + "tests.validation.strategy_boundary_test", + "tests.validation.oracle_derived_undying_test", + "tests.validation.rule_renderer_test", + "tests.ui.ui_assets_test", + "tests.ui.ui_usability_test", +) + + +def run(command: list[str], *, environment: dict[str, str]) -> None: + print("+", " ".join(command), flush=True) + subprocess.run(command, cwd=ROOT, env=environment, check=True) + + +def main() -> int: + environment = os.environ.copy() + environment["PYTHONPATH"] = str(ROOT) + environment.setdefault("MAGICAI_QUIET_EVALUATION", "1") + + with tempfile.TemporaryDirectory(prefix="magicai-ci-") as directory: + environment.setdefault( + "MAGICAI_CONVERSATION_DB", + str(Path(directory) / "conversations.sqlite3"), + ) + + run( + [sys.executable, "-m", "compileall", "-q", "magicai", "tests", "scripts"], + environment=environment, + ) + + if (ROOT / ".git").exists(): + run(["git", "diff", "--check"], environment=environment) + + bash = shutil.which("bash") + if bash: + for script in sorted((ROOT / "scripts").glob("*.sh")): + run([bash, "-n", str(script.relative_to(ROOT))], environment=environment) + + node = shutil.which("node") + if node: + run([node, "--check", "magicai/ui/static/app.js"], environment=environment) + + for module in TEST_MODULES: + run([sys.executable, "-m", module], environment=environment) + + print() + print(f"MagicAI focused CI: {len(TEST_MODULES)}/{len(TEST_MODULES)} modules passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/export_github_analysis.sh b/scripts/export_github_analysis.sh old mode 100755 new mode 100644 index 786cc60..fe65251 --- a/scripts/export_github_analysis.sh +++ b/scripts/export_github_analysis.sh @@ -1,798 +1,798 @@ -#!/usr/bin/env bash - -set -Eeuo pipefail - -command -v gh >/dev/null || { - echo "Error: GitHub CLI (gh) is required." - exit 1 -} - -command -v jq >/dev/null || { - echo "Error: jq is required." - exit 1 -} - -command -v python3 >/dev/null || { - echo "Error: python3 is required." - exit 1 -} - -git rev-parse --is-inside-work-tree >/dev/null 2>&1 || { - echo "Error: run this script inside the Git repository." - exit 1 -} - -gh auth status >/dev/null 2>&1 || { - echo "Error: authenticate first with: gh auth login" - exit 1 -} - -TIMESTAMP="$(date +%Y%m%d-%H%M%S)" -OUT="github-analysis-${TIMESTAMP}" - -mkdir -p \ - "$OUT/api/stats" \ - "$OUT/api/traffic" \ - "$OUT/api/pr_details" \ - "$OUT/git" \ - "$OUT/csv" - -ERROR_LOG="$OUT/api_errors.log" -touch "$ERROR_LOG" - -echo "Exporting repository analysis to: $OUT" - -api_object() { - local endpoint="$1" - local output="$2" - - if ! gh api "$endpoint" > "$output" 2>>"$ERROR_LOG"; then - echo '{"available":false,"reason":"API request failed or permission unavailable"}' > "$output" - fi -} - -api_array() { - local endpoint="$1" - local output="$2" - - if ! gh api --paginate "$endpoint" --jq '.[]' 2>>"$ERROR_LOG" | - jq -s '.' > "$output"; then - echo '[]' > "$output" - fi -} - -stat_api() { - local endpoint="$1" - local output="$2" - local temporary="${output}.tmp" - - for attempt in 1 2 3 4 5; do - rm -f "$temporary" - - gh api "$endpoint" > "$temporary" 2>>"$ERROR_LOG" || true - - if [[ -s "$temporary" ]]; then - mv "$temporary" "$output" - return 0 - fi - - sleep 3 - done - - rm -f "$temporary" - - cat > "$output" < "$OUT/api/contributors.json" - -rm -f "$OUT/api/contributors_raw.json" - -# --------------------------------------------------------------------------- -# Branches, tags, and releases -# --------------------------------------------------------------------------- - -api_array "repos/{owner}/{repo}/branches?per_page=100" \ - "$OUT/api/branches_raw.json" - -jq ' -map({ - name, - sha: .commit.sha, - protected -}) -' "$OUT/api/branches_raw.json" > "$OUT/api/branches.json" - -rm -f "$OUT/api/branches_raw.json" - -api_array "repos/{owner}/{repo}/tags?per_page=100" \ - "$OUT/api/tags_raw.json" - -jq ' -map({ - name, - sha: .commit.sha, - tarball_url, - zipball_url -}) -' "$OUT/api/tags_raw.json" > "$OUT/api/tags.json" - -rm -f "$OUT/api/tags_raw.json" - -api_array "repos/{owner}/{repo}/releases?per_page=100" \ - "$OUT/api/releases_raw.json" - -jq ' -map({ - id, - tag_name, - name, - draft, - prerelease, - created_at, - published_at, - author: (.author.login // null), - assets_count: (.assets | length) -}) -' "$OUT/api/releases_raw.json" > "$OUT/api/releases.json" - -rm -f "$OUT/api/releases_raw.json" - -# --------------------------------------------------------------------------- -# Issues -# The issues endpoint also returns pull requests, which are filtered out. -# Bodies and comments are intentionally not exported. -# --------------------------------------------------------------------------- - -api_array "repos/{owner}/{repo}/issues?state=all&per_page=100" \ - "$OUT/api/issues_raw.json" - -jq ' -map( - select((has("pull_request") | not)) - | { - number, - title, - state, - state_reason, - created_at, - updated_at, - closed_at, - author: (.user.login // null), - comments, - locked, - labels: [.labels[].name], - assignees: [.assignees[].login], - milestone: (.milestone.title // null) - } -) -' "$OUT/api/issues_raw.json" > "$OUT/api/issues.json" - -rm -f "$OUT/api/issues_raw.json" - -# --------------------------------------------------------------------------- -# Pull requests -# Fetch each pull request to obtain additions, deletions, commits, and changed files. -# --------------------------------------------------------------------------- - -api_array "repos/{owner}/{repo}/pulls?state=all&per_page=100" \ - "$OUT/api/pulls_index.json" - -while IFS= read -r pr_number; do - [[ -z "$pr_number" ]] && continue - - output="$OUT/api/pr_details/${pr_number}.json" - - if gh api "repos/{owner}/{repo}/pulls/${pr_number}" 2>>"$ERROR_LOG" | - jq '{ - number, - title, - state, - draft, - created_at, - updated_at, - closed_at, - merged_at, - author: (.user.login // null), - merged_by: (.merged_by.login // null), - base_branch: .base.ref, - head_branch: .head.ref, - mergeable, - mergeable_state, - commits, - additions, - deletions, - changed_files, - comments, - review_comments, - maintainer_can_modify, - labels: [.labels[].name] - }' > "$output"; then - : - else - rm -f "$output" - fi -done < <(jq -r '.[].number' "$OUT/api/pulls_index.json") - -shopt -s nullglob -PR_FILES=("$OUT"/api/pr_details/*.json) - -if (( ${#PR_FILES[@]} > 0 )); then - jq -s 'sort_by(.number)' "${PR_FILES[@]}" > "$OUT/api/pulls.json" -else - echo '[]' > "$OUT/api/pulls.json" -fi - -rm -f "$OUT/api/pulls_index.json" - -# --------------------------------------------------------------------------- -# GitHub Actions -# --------------------------------------------------------------------------- - -if gh api --paginate \ - "repos/{owner}/{repo}/actions/workflows?per_page=100" \ - --jq '.workflows[]' 2>>"$ERROR_LOG" | - jq -s 'map({ - id, - name, - path, - state, - created_at, - updated_at - })' > "$OUT/api/workflows.json"; then - : -else - echo '[]' > "$OUT/api/workflows.json" -fi - -if gh api --paginate \ - "repos/{owner}/{repo}/actions/runs?per_page=100" \ - --jq '.workflow_runs[]' 2>>"$ERROR_LOG" | - jq -s 'map({ - id, - name, - display_title, - event, - status, - conclusion, - workflow_id, - run_number, - run_attempt, - head_branch, - head_sha, - created_at, - run_started_at, - updated_at, - actor: (.actor.login // null) - })' > "$OUT/api/workflow_runs.json"; then - : -else - echo '[]' > "$OUT/api/workflow_runs.json" -fi - -# --------------------------------------------------------------------------- -# Statistics calculated by GitHub -# --------------------------------------------------------------------------- - -stat_api "repos/{owner}/{repo}/stats/contributors" \ - "$OUT/api/stats/contributors.json" - -stat_api "repos/{owner}/{repo}/stats/commit_activity" \ - "$OUT/api/stats/commit_activity.json" - -stat_api "repos/{owner}/{repo}/stats/code_frequency" \ - "$OUT/api/stats/code_frequency.json" - -stat_api "repos/{owner}/{repo}/stats/participation" \ - "$OUT/api/stats/participation.json" - -stat_api "repos/{owner}/{repo}/stats/punch_card" \ - "$OUT/api/stats/punch_card.json" - -# --------------------------------------------------------------------------- -# Traffic data requires sufficient permissions and covers a limited recent window. -# --------------------------------------------------------------------------- - -api_object "repos/{owner}/{repo}/traffic/clones" \ - "$OUT/api/traffic/clones.json" - -api_object "repos/{owner}/{repo}/traffic/views" \ - "$OUT/api/traffic/views.json" - -api_object "repos/{owner}/{repo}/traffic/popular/paths" \ - "$OUT/api/traffic/popular_paths.json" - -api_object "repos/{owner}/{repo}/traffic/popular/referrers" \ - "$OUT/api/traffic/popular_referrers.json" - -# --------------------------------------------------------------------------- -# Local Git history -# --------------------------------------------------------------------------- - -git remote -v > "$OUT/git/remotes.txt" -git branch -a -vv > "$OUT/git/branches.txt" -git tag --sort=-creatordate > "$OUT/git/tags.txt" -git status --short --branch > "$OUT/git/status.txt" -git count-objects -vH > "$OUT/git/storage.txt" -git shortlog -s -n --all > "$OUT/git/shortlog.txt" -git ls-files > "$OUT/git/tracked_files.txt" - -git rev-list --count --all > "$OUT/git/total_commits_all_refs.txt" -git rev-list --count HEAD > "$OUT/git/total_commits_current_branch.txt" - -# Generate history CSV files without exporting author emails or file contents. -python3 - "$OUT" <<'PY' -import csv -import json -import os -import pathlib -import subprocess -import sys -from collections import defaultdict -from datetime import datetime - -out = pathlib.Path(sys.argv[1]) - -marker = "__GITHUB_ANALYSIS_COMMIT__" -separator = "\x1f" - -pretty = ( - marker - + separator + "%H" - + separator + "%aI" - + separator + "%an" - + separator + "%P" - + separator + "%s" -) - -result = subprocess.run( - [ - "git", - "log", - "--all", - "--numstat", - "--date=iso-strict", - f"--pretty=format:{pretty}", - ], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - errors="replace", - check=True, -) - -commits = [] -current = None - -def finish_current(): - global current - - if current is not None: - commits.append(current) - current = None - -for line in result.stdout.splitlines(): - if line.startswith(marker + separator): - finish_current() - - parts = line.split(separator) - - if len(parts) < 6: - continue - - parents = parts[4].split() if parts[4] else [] - - current = { - "sha": parts[1], - "date": parts[2], - "author": parts[3], - "parent_count": len(parents), - "subject": separator.join(parts[5:]), - "files_changed": 0, - "additions": 0, - "deletions": 0, - "binary_files": 0, - } - - continue - - if current is None or "\t" not in line: - continue - - fields = line.split("\t", 2) - - if len(fields) != 3: - continue - - added, deleted, _filename = fields - - current["files_changed"] += 1 - - if added.isdigit(): - current["additions"] += int(added) - else: - current["binary_files"] += 1 - - if deleted.isdigit(): - current["deletions"] += int(deleted) - -finish_current() - -commits.sort(key=lambda item: item["date"]) - -commit_fields = [ - "sha", - "date", - "author", - "parent_count", - "subject", - "files_changed", - "additions", - "deletions", - "binary_files", -] - -with (out / "csv" / "commits.csv").open( - "w", newline="", encoding="utf-8" -) as handle: - writer = csv.DictWriter(handle, fieldnames=commit_fields) - writer.writeheader() - writer.writerows(commits) - -authors = defaultdict( - lambda: { - "commits": 0, - "merge_commits": 0, - "files_changed": 0, - "additions": 0, - "deletions": 0, - } -) - -months = defaultdict( - lambda: { - "commits": 0, - "merge_commits": 0, - "files_changed": 0, - "additions": 0, - "deletions": 0, - } -) - -weekdays = defaultdict(int) -hours = defaultdict(int) - -for commit in commits: - author_stats = authors[commit["author"]] - author_stats["commits"] += 1 - author_stats["merge_commits"] += int(commit["parent_count"] > 1) - author_stats["files_changed"] += commit["files_changed"] - author_stats["additions"] += commit["additions"] - author_stats["deletions"] += commit["deletions"] - - month = commit["date"][:7] if commit["date"] else "unknown" - month_stats = months[month] - month_stats["commits"] += 1 - month_stats["merge_commits"] += int(commit["parent_count"] > 1) - month_stats["files_changed"] += commit["files_changed"] - month_stats["additions"] += commit["additions"] - month_stats["deletions"] += commit["deletions"] - - try: - parsed = datetime.fromisoformat(commit["date"]) - weekdays[parsed.strftime("%A")] += 1 - hours[parsed.hour] += 1 - except (TypeError, ValueError): - pass - -with (out / "csv" / "authors.csv").open( - "w", newline="", encoding="utf-8" -) as handle: - fieldnames = [ - "author", - "commits", - "merge_commits", - "files_changed", - "additions", - "deletions", - "net_lines", - ] - - writer = csv.DictWriter(handle, fieldnames=fieldnames) - writer.writeheader() - - for author, stats in sorted( - authors.items(), - key=lambda item: item[1]["commits"], - reverse=True, - ): - writer.writerow( - { - "author": author, - **stats, - "net_lines": stats["additions"] - stats["deletions"], - } - ) - -with (out / "csv" / "monthly_activity.csv").open( - "w", newline="", encoding="utf-8" -) as handle: - fieldnames = [ - "month", - "commits", - "merge_commits", - "files_changed", - "additions", - "deletions", - "net_lines", - ] - - writer = csv.DictWriter(handle, fieldnames=fieldnames) - writer.writeheader() - - for month in sorted(months): - stats = months[month] - writer.writerow( - { - "month": month, - **stats, - "net_lines": stats["additions"] - stats["deletions"], - } - ) - -with (out / "csv" / "commit_hours.csv").open( - "w", newline="", encoding="utf-8" -) as handle: - writer = csv.writer(handle) - writer.writerow(["hour", "commits"]) - - for hour in range(24): - writer.writerow([hour, hours[hour]]) - -with (out / "csv" / "commit_weekdays.csv").open( - "w", newline="", encoding="utf-8" -) as handle: - writer = csv.writer(handle) - writer.writerow(["weekday", "commits"]) - - ordered_days = [ - "Monday", - "Tuesday", - "Wednesday", - "Thursday", - "Friday", - "Saturday", - "Sunday", - ] - - for day in ordered_days: - writer.writerow([day, weekdays[day]]) - -# Tracked-file statistics without copying file contents. -tracked = subprocess.run( - ["git", "ls-files", "-z"], - stdout=subprocess.PIPE, - check=True, -).stdout.split(b"\0") - -extensions = defaultdict( - lambda: { - "files": 0, - "bytes": 0, - } -) - -total_files = 0 -total_bytes = 0 - -for raw_path in tracked: - if not raw_path: - continue - - path_text = raw_path.decode("utf-8", errors="replace") - path = pathlib.Path(path_text) - - suffix = path.suffix.lower() or "[no extension]" - - try: - size = path.stat().st_size - except OSError: - size = 0 - - extensions[suffix]["files"] += 1 - extensions[suffix]["bytes"] += size - - total_files += 1 - total_bytes += size - -with (out / "csv" / "file_types.csv").open( - "w", newline="", encoding="utf-8" -) as handle: - writer = csv.writer(handle) - writer.writerow(["extension", "files", "bytes"]) - - for extension, values in sorted( - extensions.items(), - key=lambda item: item[1]["bytes"], - reverse=True, - ): - writer.writerow( - [ - extension, - values["files"], - values["bytes"], - ] - ) - -summary = { - "generated_at": datetime.now().astimezone().isoformat(), - "commits_all_refs": len(commits), - "authors": len(authors), - "first_commit": commits[0]["date"] if commits else None, - "last_commit": commits[-1]["date"] if commits else None, - "total_additions": sum(commit["additions"] for commit in commits), - "total_deletions": sum(commit["deletions"] for commit in commits), - "merge_commits": sum(commit["parent_count"] > 1 for commit in commits), - "tracked_files": total_files, - "tracked_file_bytes": total_bytes, -} - -with (out / "git" / "local_summary.json").open( - "w", encoding="utf-8" -) as handle: - json.dump(summary, handle, ensure_ascii=False, indent=2) - handle.write("\n") -PY - -# --------------------------------------------------------------------------- -# CLOC language statistics -# --------------------------------------------------------------------------- - -if command -v cloc >/dev/null 2>&1; then - cloc \ - --list-file="$OUT/git/tracked_files.txt" \ - --json \ - --quiet \ - --out="$OUT/git/cloc.json" \ - 2>>"$ERROR_LOG" || true -else - cat > "$OUT/git/cloc.json" < "$OUT/csv/contributors.csv" - -jq -r ' -(["number","state","created_at","closed_at","author","comments","title","labels"] | @csv), -(.[] | [ - .number, - .state, - .created_at, - .closed_at, - .author, - .comments, - .title, - (.labels | join("; ")) -] | @csv) -' "$OUT/api/issues.json" > "$OUT/csv/issues.csv" - -jq -r ' -(["number","state","draft","created_at","merged_at","author","base_branch","head_branch","commits","additions","deletions","changed_files","title"] | @csv), -(.[] | [ - .number, - .state, - .draft, - .created_at, - .merged_at, - .author, - .base_branch, - .head_branch, - .commits, - .additions, - .deletions, - .changed_files, - .title -] | @csv) -' "$OUT/api/pulls.json" > "$OUT/csv/pull_requests.csv" - -jq -r ' -(["id","name","event","status","conclusion","run_number","head_branch","created_at","run_started_at","updated_at","actor"] | @csv), -(.[] | [ - .id, - .name, - .event, - .status, - .conclusion, - .run_number, - .head_branch, - .created_at, - .run_started_at, - .updated_at, - .actor -] | @csv) -' "$OUT/api/workflow_runs.json" > "$OUT/csv/workflow_runs.csv" - -# --------------------------------------------------------------------------- -# Human-readable summary -# --------------------------------------------------------------------------- - -REPO_NAME="$( - jq -r '.full_name // "unknown"' "$OUT/api/repository.json" -)" - -{ - echo "GitHub repository analysis export" - echo "Repository: $REPO_NAME" - echo "Generated: $(date --iso-8601=seconds)" - echo - echo "Main files:" - echo "- api/repository.json" - echo "- api/issues.json" - echo "- api/pulls.json" - echo "- api/contributors.json" - echo "- api/workflow_runs.json" - echo "- api/stats/" - echo "- api/traffic/" - echo "- git/local_summary.json" - echo "- git/cloc.json" - echo "- csv/commits.csv" - echo "- csv/monthly_activity.csv" - echo "- csv/authors.csv" - echo "- csv/file_types.csv" -} > "$OUT/README.txt" - -ZIP="${OUT}.zip" - -zip -qr "$ZIP" "$OUT" - -echo -echo "Export completed." -echo "Directory: $OUT" -echo "Archive: $ZIP" +#!/usr/bin/env bash + +set -Eeuo pipefail + +command -v gh >/dev/null || { + echo "Error: GitHub CLI (gh) is required." + exit 1 +} + +command -v jq >/dev/null || { + echo "Error: jq is required." + exit 1 +} + +command -v python3 >/dev/null || { + echo "Error: python3 is required." + exit 1 +} + +git rev-parse --is-inside-work-tree >/dev/null 2>&1 || { + echo "Error: run this script inside the Git repository." + exit 1 +} + +gh auth status >/dev/null 2>&1 || { + echo "Error: authenticate first with: gh auth login" + exit 1 +} + +TIMESTAMP="$(date +%Y%m%d-%H%M%S)" +OUT="github-analysis-${TIMESTAMP}" + +mkdir -p \ + "$OUT/api/stats" \ + "$OUT/api/traffic" \ + "$OUT/api/pr_details" \ + "$OUT/git" \ + "$OUT/csv" + +ERROR_LOG="$OUT/api_errors.log" +touch "$ERROR_LOG" + +echo "Exporting repository analysis to: $OUT" + +api_object() { + local endpoint="$1" + local output="$2" + + if ! gh api "$endpoint" > "$output" 2>>"$ERROR_LOG"; then + echo '{"available":false,"reason":"API request failed or permission unavailable"}' > "$output" + fi +} + +api_array() { + local endpoint="$1" + local output="$2" + + if ! gh api --paginate "$endpoint" --jq '.[]' 2>>"$ERROR_LOG" | + jq -s '.' > "$output"; then + echo '[]' > "$output" + fi +} + +stat_api() { + local endpoint="$1" + local output="$2" + local temporary="${output}.tmp" + + for attempt in 1 2 3 4 5; do + rm -f "$temporary" + + gh api "$endpoint" > "$temporary" 2>>"$ERROR_LOG" || true + + if [[ -s "$temporary" ]]; then + mv "$temporary" "$output" + return 0 + fi + + sleep 3 + done + + rm -f "$temporary" + + cat > "$output" < "$OUT/api/contributors.json" + +rm -f "$OUT/api/contributors_raw.json" + +# --------------------------------------------------------------------------- +# Branches, tags, and releases +# --------------------------------------------------------------------------- + +api_array "repos/{owner}/{repo}/branches?per_page=100" \ + "$OUT/api/branches_raw.json" + +jq ' +map({ + name, + sha: .commit.sha, + protected +}) +' "$OUT/api/branches_raw.json" > "$OUT/api/branches.json" + +rm -f "$OUT/api/branches_raw.json" + +api_array "repos/{owner}/{repo}/tags?per_page=100" \ + "$OUT/api/tags_raw.json" + +jq ' +map({ + name, + sha: .commit.sha, + tarball_url, + zipball_url +}) +' "$OUT/api/tags_raw.json" > "$OUT/api/tags.json" + +rm -f "$OUT/api/tags_raw.json" + +api_array "repos/{owner}/{repo}/releases?per_page=100" \ + "$OUT/api/releases_raw.json" + +jq ' +map({ + id, + tag_name, + name, + draft, + prerelease, + created_at, + published_at, + author: (.author.login // null), + assets_count: (.assets | length) +}) +' "$OUT/api/releases_raw.json" > "$OUT/api/releases.json" + +rm -f "$OUT/api/releases_raw.json" + +# --------------------------------------------------------------------------- +# Issues +# The issues endpoint also returns pull requests, which are filtered out. +# Bodies and comments are intentionally not exported. +# --------------------------------------------------------------------------- + +api_array "repos/{owner}/{repo}/issues?state=all&per_page=100" \ + "$OUT/api/issues_raw.json" + +jq ' +map( + select((has("pull_request") | not)) + | { + number, + title, + state, + state_reason, + created_at, + updated_at, + closed_at, + author: (.user.login // null), + comments, + locked, + labels: [.labels[].name], + assignees: [.assignees[].login], + milestone: (.milestone.title // null) + } +) +' "$OUT/api/issues_raw.json" > "$OUT/api/issues.json" + +rm -f "$OUT/api/issues_raw.json" + +# --------------------------------------------------------------------------- +# Pull requests +# Fetch each pull request to obtain additions, deletions, commits, and changed files. +# --------------------------------------------------------------------------- + +api_array "repos/{owner}/{repo}/pulls?state=all&per_page=100" \ + "$OUT/api/pulls_index.json" + +while IFS= read -r pr_number; do + [[ -z "$pr_number" ]] && continue + + output="$OUT/api/pr_details/${pr_number}.json" + + if gh api "repos/{owner}/{repo}/pulls/${pr_number}" 2>>"$ERROR_LOG" | + jq '{ + number, + title, + state, + draft, + created_at, + updated_at, + closed_at, + merged_at, + author: (.user.login // null), + merged_by: (.merged_by.login // null), + base_branch: .base.ref, + head_branch: .head.ref, + mergeable, + mergeable_state, + commits, + additions, + deletions, + changed_files, + comments, + review_comments, + maintainer_can_modify, + labels: [.labels[].name] + }' > "$output"; then + : + else + rm -f "$output" + fi +done < <(jq -r '.[].number' "$OUT/api/pulls_index.json") + +shopt -s nullglob +PR_FILES=("$OUT"/api/pr_details/*.json) + +if (( ${#PR_FILES[@]} > 0 )); then + jq -s 'sort_by(.number)' "${PR_FILES[@]}" > "$OUT/api/pulls.json" +else + echo '[]' > "$OUT/api/pulls.json" +fi + +rm -f "$OUT/api/pulls_index.json" + +# --------------------------------------------------------------------------- +# GitHub Actions +# --------------------------------------------------------------------------- + +if gh api --paginate \ + "repos/{owner}/{repo}/actions/workflows?per_page=100" \ + --jq '.workflows[]' 2>>"$ERROR_LOG" | + jq -s 'map({ + id, + name, + path, + state, + created_at, + updated_at + })' > "$OUT/api/workflows.json"; then + : +else + echo '[]' > "$OUT/api/workflows.json" +fi + +if gh api --paginate \ + "repos/{owner}/{repo}/actions/runs?per_page=100" \ + --jq '.workflow_runs[]' 2>>"$ERROR_LOG" | + jq -s 'map({ + id, + name, + display_title, + event, + status, + conclusion, + workflow_id, + run_number, + run_attempt, + head_branch, + head_sha, + created_at, + run_started_at, + updated_at, + actor: (.actor.login // null) + })' > "$OUT/api/workflow_runs.json"; then + : +else + echo '[]' > "$OUT/api/workflow_runs.json" +fi + +# --------------------------------------------------------------------------- +# Statistics calculated by GitHub +# --------------------------------------------------------------------------- + +stat_api "repos/{owner}/{repo}/stats/contributors" \ + "$OUT/api/stats/contributors.json" + +stat_api "repos/{owner}/{repo}/stats/commit_activity" \ + "$OUT/api/stats/commit_activity.json" + +stat_api "repos/{owner}/{repo}/stats/code_frequency" \ + "$OUT/api/stats/code_frequency.json" + +stat_api "repos/{owner}/{repo}/stats/participation" \ + "$OUT/api/stats/participation.json" + +stat_api "repos/{owner}/{repo}/stats/punch_card" \ + "$OUT/api/stats/punch_card.json" + +# --------------------------------------------------------------------------- +# Traffic data requires sufficient permissions and covers a limited recent window. +# --------------------------------------------------------------------------- + +api_object "repos/{owner}/{repo}/traffic/clones" \ + "$OUT/api/traffic/clones.json" + +api_object "repos/{owner}/{repo}/traffic/views" \ + "$OUT/api/traffic/views.json" + +api_object "repos/{owner}/{repo}/traffic/popular/paths" \ + "$OUT/api/traffic/popular_paths.json" + +api_object "repos/{owner}/{repo}/traffic/popular/referrers" \ + "$OUT/api/traffic/popular_referrers.json" + +# --------------------------------------------------------------------------- +# Local Git history +# --------------------------------------------------------------------------- + +git remote -v > "$OUT/git/remotes.txt" +git branch -a -vv > "$OUT/git/branches.txt" +git tag --sort=-creatordate > "$OUT/git/tags.txt" +git status --short --branch > "$OUT/git/status.txt" +git count-objects -vH > "$OUT/git/storage.txt" +git shortlog -s -n --all > "$OUT/git/shortlog.txt" +git ls-files > "$OUT/git/tracked_files.txt" + +git rev-list --count --all > "$OUT/git/total_commits_all_refs.txt" +git rev-list --count HEAD > "$OUT/git/total_commits_current_branch.txt" + +# Generate history CSV files without exporting author emails or file contents. +python3 - "$OUT" <<'PY' +import csv +import json +import os +import pathlib +import subprocess +import sys +from collections import defaultdict +from datetime import datetime + +out = pathlib.Path(sys.argv[1]) + +marker = "__GITHUB_ANALYSIS_COMMIT__" +separator = "\x1f" + +pretty = ( + marker + + separator + "%H" + + separator + "%aI" + + separator + "%an" + + separator + "%P" + + separator + "%s" +) + +result = subprocess.run( + [ + "git", + "log", + "--all", + "--numstat", + "--date=iso-strict", + f"--pretty=format:{pretty}", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + errors="replace", + check=True, +) + +commits = [] +current = None + +def finish_current(): + global current + + if current is not None: + commits.append(current) + current = None + +for line in result.stdout.splitlines(): + if line.startswith(marker + separator): + finish_current() + + parts = line.split(separator) + + if len(parts) < 6: + continue + + parents = parts[4].split() if parts[4] else [] + + current = { + "sha": parts[1], + "date": parts[2], + "author": parts[3], + "parent_count": len(parents), + "subject": separator.join(parts[5:]), + "files_changed": 0, + "additions": 0, + "deletions": 0, + "binary_files": 0, + } + + continue + + if current is None or "\t" not in line: + continue + + fields = line.split("\t", 2) + + if len(fields) != 3: + continue + + added, deleted, _filename = fields + + current["files_changed"] += 1 + + if added.isdigit(): + current["additions"] += int(added) + else: + current["binary_files"] += 1 + + if deleted.isdigit(): + current["deletions"] += int(deleted) + +finish_current() + +commits.sort(key=lambda item: item["date"]) + +commit_fields = [ + "sha", + "date", + "author", + "parent_count", + "subject", + "files_changed", + "additions", + "deletions", + "binary_files", +] + +with (out / "csv" / "commits.csv").open( + "w", newline="", encoding="utf-8" +) as handle: + writer = csv.DictWriter(handle, fieldnames=commit_fields) + writer.writeheader() + writer.writerows(commits) + +authors = defaultdict( + lambda: { + "commits": 0, + "merge_commits": 0, + "files_changed": 0, + "additions": 0, + "deletions": 0, + } +) + +months = defaultdict( + lambda: { + "commits": 0, + "merge_commits": 0, + "files_changed": 0, + "additions": 0, + "deletions": 0, + } +) + +weekdays = defaultdict(int) +hours = defaultdict(int) + +for commit in commits: + author_stats = authors[commit["author"]] + author_stats["commits"] += 1 + author_stats["merge_commits"] += int(commit["parent_count"] > 1) + author_stats["files_changed"] += commit["files_changed"] + author_stats["additions"] += commit["additions"] + author_stats["deletions"] += commit["deletions"] + + month = commit["date"][:7] if commit["date"] else "unknown" + month_stats = months[month] + month_stats["commits"] += 1 + month_stats["merge_commits"] += int(commit["parent_count"] > 1) + month_stats["files_changed"] += commit["files_changed"] + month_stats["additions"] += commit["additions"] + month_stats["deletions"] += commit["deletions"] + + try: + parsed = datetime.fromisoformat(commit["date"]) + weekdays[parsed.strftime("%A")] += 1 + hours[parsed.hour] += 1 + except (TypeError, ValueError): + pass + +with (out / "csv" / "authors.csv").open( + "w", newline="", encoding="utf-8" +) as handle: + fieldnames = [ + "author", + "commits", + "merge_commits", + "files_changed", + "additions", + "deletions", + "net_lines", + ] + + writer = csv.DictWriter(handle, fieldnames=fieldnames) + writer.writeheader() + + for author, stats in sorted( + authors.items(), + key=lambda item: item[1]["commits"], + reverse=True, + ): + writer.writerow( + { + "author": author, + **stats, + "net_lines": stats["additions"] - stats["deletions"], + } + ) + +with (out / "csv" / "monthly_activity.csv").open( + "w", newline="", encoding="utf-8" +) as handle: + fieldnames = [ + "month", + "commits", + "merge_commits", + "files_changed", + "additions", + "deletions", + "net_lines", + ] + + writer = csv.DictWriter(handle, fieldnames=fieldnames) + writer.writeheader() + + for month in sorted(months): + stats = months[month] + writer.writerow( + { + "month": month, + **stats, + "net_lines": stats["additions"] - stats["deletions"], + } + ) + +with (out / "csv" / "commit_hours.csv").open( + "w", newline="", encoding="utf-8" +) as handle: + writer = csv.writer(handle) + writer.writerow(["hour", "commits"]) + + for hour in range(24): + writer.writerow([hour, hours[hour]]) + +with (out / "csv" / "commit_weekdays.csv").open( + "w", newline="", encoding="utf-8" +) as handle: + writer = csv.writer(handle) + writer.writerow(["weekday", "commits"]) + + ordered_days = [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday", + ] + + for day in ordered_days: + writer.writerow([day, weekdays[day]]) + +# Tracked-file statistics without copying file contents. +tracked = subprocess.run( + ["git", "ls-files", "-z"], + stdout=subprocess.PIPE, + check=True, +).stdout.split(b"\0") + +extensions = defaultdict( + lambda: { + "files": 0, + "bytes": 0, + } +) + +total_files = 0 +total_bytes = 0 + +for raw_path in tracked: + if not raw_path: + continue + + path_text = raw_path.decode("utf-8", errors="replace") + path = pathlib.Path(path_text) + + suffix = path.suffix.lower() or "[no extension]" + + try: + size = path.stat().st_size + except OSError: + size = 0 + + extensions[suffix]["files"] += 1 + extensions[suffix]["bytes"] += size + + total_files += 1 + total_bytes += size + +with (out / "csv" / "file_types.csv").open( + "w", newline="", encoding="utf-8" +) as handle: + writer = csv.writer(handle) + writer.writerow(["extension", "files", "bytes"]) + + for extension, values in sorted( + extensions.items(), + key=lambda item: item[1]["bytes"], + reverse=True, + ): + writer.writerow( + [ + extension, + values["files"], + values["bytes"], + ] + ) + +summary = { + "generated_at": datetime.now().astimezone().isoformat(), + "commits_all_refs": len(commits), + "authors": len(authors), + "first_commit": commits[0]["date"] if commits else None, + "last_commit": commits[-1]["date"] if commits else None, + "total_additions": sum(commit["additions"] for commit in commits), + "total_deletions": sum(commit["deletions"] for commit in commits), + "merge_commits": sum(commit["parent_count"] > 1 for commit in commits), + "tracked_files": total_files, + "tracked_file_bytes": total_bytes, +} + +with (out / "git" / "local_summary.json").open( + "w", encoding="utf-8" +) as handle: + json.dump(summary, handle, ensure_ascii=False, indent=2) + handle.write("\n") +PY + +# --------------------------------------------------------------------------- +# CLOC language statistics +# --------------------------------------------------------------------------- + +if command -v cloc >/dev/null 2>&1; then + cloc \ + --list-file="$OUT/git/tracked_files.txt" \ + --json \ + --quiet \ + --out="$OUT/git/cloc.json" \ + 2>>"$ERROR_LOG" || true +else + cat > "$OUT/git/cloc.json" < "$OUT/csv/contributors.csv" + +jq -r ' +(["number","state","created_at","closed_at","author","comments","title","labels"] | @csv), +(.[] | [ + .number, + .state, + .created_at, + .closed_at, + .author, + .comments, + .title, + (.labels | join("; ")) +] | @csv) +' "$OUT/api/issues.json" > "$OUT/csv/issues.csv" + +jq -r ' +(["number","state","draft","created_at","merged_at","author","base_branch","head_branch","commits","additions","deletions","changed_files","title"] | @csv), +(.[] | [ + .number, + .state, + .draft, + .created_at, + .merged_at, + .author, + .base_branch, + .head_branch, + .commits, + .additions, + .deletions, + .changed_files, + .title +] | @csv) +' "$OUT/api/pulls.json" > "$OUT/csv/pull_requests.csv" + +jq -r ' +(["id","name","event","status","conclusion","run_number","head_branch","created_at","run_started_at","updated_at","actor"] | @csv), +(.[] | [ + .id, + .name, + .event, + .status, + .conclusion, + .run_number, + .head_branch, + .created_at, + .run_started_at, + .updated_at, + .actor +] | @csv) +' "$OUT/api/workflow_runs.json" > "$OUT/csv/workflow_runs.csv" + +# --------------------------------------------------------------------------- +# Human-readable summary +# --------------------------------------------------------------------------- + +REPO_NAME="$( + jq -r '.full_name // "unknown"' "$OUT/api/repository.json" +)" + +{ + echo "GitHub repository analysis export" + echo "Repository: $REPO_NAME" + echo "Generated: $(date --iso-8601=seconds)" + echo + echo "Main files:" + echo "- api/repository.json" + echo "- api/issues.json" + echo "- api/pulls.json" + echo "- api/contributors.json" + echo "- api/workflow_runs.json" + echo "- api/stats/" + echo "- api/traffic/" + echo "- git/local_summary.json" + echo "- git/cloc.json" + echo "- csv/commits.csv" + echo "- csv/monthly_activity.csv" + echo "- csv/authors.csv" + echo "- csv/file_types.csv" +} > "$OUT/README.txt" + +ZIP="${OUT}.zip" + +zip -qr "$ZIP" "$OUT" + +echo +echo "Export completed." +echo "Directory: $OUT" +echo "Archive: $ZIP" diff --git a/scripts/package_release.py b/scripts/package_release.py old mode 100755 new mode 100644 index fccb04c..cdce5f3 --- a/scripts/package_release.py +++ b/scripts/package_release.py @@ -1,404 +1,404 @@ -#!/usr/bin/env python3 -"""Build clean, deterministic MagicAI source and full release archives.""" - -from __future__ import annotations - -import argparse -from dataclasses import dataclass -from datetime import datetime, timezone -import hashlib -import importlib.util -import json -import os -from pathlib import Path, PurePosixPath -import stat -import subprocess -import sys -import tomllib -import zipfile - - -FULL_SOURCE_FILES = ( - PurePosixPath("sources/scryfall/oracle-cards.json"), - PurePosixPath("sources/scryfall/rulings.json"), -) - -EXCLUDED_PREFIXES = ( - ".git/", - ".venv/", - "backups/", - "backup/", - "build/", - "dist/", - "github-analysis-", - "htmlcov/", - "logs/", - "node_modules/", - "quality-results/", - "reports/", - "resultado_", - "site/", - "test-results/", -) - -EXCLUDED_PARTS = { - "__pycache__", - ".mypy_cache", - ".pytest_cache", - ".ruff_cache", -} - - -@dataclass(frozen=True, slots=True) -class ReleaseIdentity: - public_version: str - package_version: str - tag: str - channel: str - codename: str - - -@dataclass(frozen=True, slots=True) -class PackageEntry: - relative_path: PurePosixPath - source_path: Path - executable: bool - - -def run_git(repo: Path, *arguments: str, text: bool = True) -> str | bytes: - result = subprocess.run( - ["git", "-C", str(repo), *arguments], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - check=False, - text=text, - ) - if result.returncode != 0: - detail = result.stderr.strip() if text else result.stderr.decode(errors="replace").strip() - raise RuntimeError(f"git {' '.join(arguments)} failed: {detail}") - return result.stdout - - -def repository_root(configured: str | None) -> Path: - candidate = Path(configured or Path.cwd()).expanduser().resolve() - output = subprocess.run( - ["git", "-C", str(candidate), "rev-parse", "--show-toplevel"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - check=False, - ) - if output.returncode != 0: - raise RuntimeError(f"{candidate} is not inside a Git repository") - return Path(output.stdout.strip()).resolve() - - -def load_release_identity(repo: Path) -> ReleaseIdentity: - module_path = repo / "magicai" / "versioning.py" - spec = importlib.util.spec_from_file_location("magicai_release_versioning", module_path) - if spec is None or spec.loader is None: - raise RuntimeError(f"Unable to load {module_path}") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - - pyproject = tomllib.loads((repo / "pyproject.toml").read_text(encoding="utf-8")) - package_version = str(pyproject["project"]["version"]) - expected_package_version = str(module.PACKAGE_FALLBACK_VERSION) - if package_version != expected_package_version: - raise RuntimeError( - "Version mismatch: pyproject.toml declares " - f"{package_version!r}, but magicai/versioning.py declares " - f"{expected_package_version!r}." - ) - - return ReleaseIdentity( - public_version=str(module.PUBLIC_VERSION), - package_version=package_version, - tag=str(module.RELEASE_TAG), - channel=str(module.RELEASE_CHANNEL), - codename=str(module.RELEASE_CODENAME), - ) - - -def tracked_paths(repo: Path) -> list[PurePosixPath]: - raw = run_git(repo, "ls-files", "-z", text=False) - assert isinstance(raw, bytes) - paths = [] - for value in raw.split(b"\0"): - if not value: - continue - paths.append(PurePosixPath(value.decode("utf-8", errors="strict"))) - return sorted(paths, key=str) - - -def should_exclude(path: PurePosixPath) -> bool: - value = path.as_posix() - if any(part in EXCLUDED_PARTS for part in path.parts): - return True - return any(value == prefix.rstrip("/") or value.startswith(prefix) for prefix in EXCLUDED_PREFIXES) - - -def collect_entries(repo: Path, mode: str) -> list[PackageEntry]: - entries: dict[PurePosixPath, PackageEntry] = {} - - for relative in tracked_paths(repo): - if should_exclude(relative): - continue - if mode == "source" and relative in FULL_SOURCE_FILES: - continue - source = repo / relative - if not source.is_file(): - raise RuntimeError(f"Tracked file is missing from the working tree: {relative}") - if source.is_symlink(): - raise RuntimeError(f"Release packages do not include symbolic links: {relative}") - executable = bool(source.stat().st_mode & stat.S_IXUSR) - entries[relative] = PackageEntry(relative, source, executable) - - if mode == "full": - missing = [path for path in FULL_SOURCE_FILES if not (repo / path).is_file()] - if missing: - formatted = ", ".join(str(path) for path in missing) - raise RuntimeError( - "Full package requires local bulk sources. Missing: " + formatted - ) - for relative in FULL_SOURCE_FILES: - source = repo / relative - entries[relative] = PackageEntry(relative, source, False) - - return [entries[path] for path in sorted(entries, key=str)] - - -def sha256_bytes(content: bytes) -> str: - return hashlib.sha256(content).hexdigest() - - -def sha256_file(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as handle: - for chunk in iter(lambda: handle.read(1024 * 1024), b""): - digest.update(chunk) - return digest.hexdigest() - - -def source_epoch(repo: Path) -> int: - configured = os.environ.get("SOURCE_DATE_EPOCH") - if configured: - try: - return max(int(configured), 315532800) - except ValueError as error: - raise RuntimeError("SOURCE_DATE_EPOCH must be an integer") from error - - output = run_git(repo, "log", "-1", "--format=%ct") - assert isinstance(output, str) - return max(int(output.strip()), 315532800) - - -def zip_datetime(epoch: int) -> tuple[int, int, int, int, int, int]: - value = datetime.fromtimestamp(epoch, tz=timezone.utc) - second = value.second - (value.second % 2) - return value.year, value.month, value.day, value.hour, value.minute, second - - -def git_metadata(repo: Path) -> dict[str, str]: - commit = str(run_git(repo, "rev-parse", "HEAD")).strip() - branch = str(run_git(repo, "branch", "--show-current")).strip() or "detached" - status = str(run_git(repo, "status", "--porcelain", "--untracked-files=no")) - return { - "commit": commit, - "branch": branch, - "tracked_worktree": "clean" if not status.strip() else "modified", - } - - -def build_metadata( - *, - repo: Path, - identity: ReleaseIdentity, - mode: str, - entries: list[PackageEntry], - epoch: int, -) -> tuple[bytes, bytes]: - git_info = git_metadata(repo) - files = [] - for entry in entries: - files.append( - { - "path": entry.relative_path.as_posix(), - "size": entry.source_path.stat().st_size, - "sha256": sha256_file(entry.source_path), - } - ) - - generated_at = datetime.fromtimestamp(epoch, tz=timezone.utc).isoformat() - manifest = { - "schema_version": "1.0", - "project": "MagicAI", - "package_mode": mode, - "public_version": identity.public_version, - "package_version": identity.package_version, - "release_tag": identity.tag, - "release_channel": identity.channel, - "release_codename": identity.codename, - "generated_at": generated_at, - "git": git_info, - "files": files, - } - manifest_bytes = ( - json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n" - ).encode("utf-8") - - info = "\n".join( - [ - "MagicAI release package", - "", - f"Public version: {identity.public_version}", - f"Package version: {identity.package_version}", - f"Codename: {identity.codename}", - f"Channel: {identity.channel}", - f"Tag: {identity.tag}", - f"Package mode: {mode}", - f"Git branch: {git_info['branch']}", - f"Git commit: {git_info['commit']}", - f"Tracked worktree: {git_info['tracked_worktree']}", - f"Generated from commit time: {generated_at}", - f"Packaged files: {len(entries)}", - "", - "See PACKAGE_MANIFEST.json for per-file SHA-256 values.", - "", - ] - ).encode("utf-8") - return info, manifest_bytes - - -def write_zip_entry( - archive: zipfile.ZipFile, - archive_name: str, - content: bytes, - *, - timestamp: tuple[int, int, int, int, int, int], - executable: bool = False, -) -> None: - info = zipfile.ZipInfo(archive_name, date_time=timestamp) - info.compress_type = zipfile.ZIP_DEFLATED - info.create_system = 3 - mode = 0o755 if executable else 0o644 - info.external_attr = (stat.S_IFREG | mode) << 16 - archive.writestr(info, content, compresslevel=9) - - -def build_archive( - *, - repo: Path, - mode: str, - output_dir: Path, - force: bool, - allow_dirty: bool = False, -) -> Path: - identity = load_release_identity(repo) - current_git = git_metadata(repo) - if current_git["tracked_worktree"] != "clean" and not allow_dirty: - raise RuntimeError( - "Tracked files are modified. Commit or restore them before packaging, " - "or use --allow-dirty for a diagnostic archive." - ) - entries = collect_entries(repo, mode) - epoch = source_epoch(repo) - timestamp = zip_datetime(epoch) - root_name = f"MagicAI-v{identity.public_version}-{mode}" - output_dir.mkdir(parents=True, exist_ok=True) - archive_path = output_dir / f"{root_name}.zip" - checksum_path = archive_path.with_suffix(archive_path.suffix + ".sha256") - - if (archive_path.exists() or checksum_path.exists()) and not force: - raise RuntimeError( - f"Output already exists: {archive_path}. Use --force to replace it." - ) - - info_bytes, manifest_bytes = build_metadata( - repo=repo, - identity=identity, - mode=mode, - entries=entries, - epoch=epoch, - ) - - temporary = archive_path.with_suffix(".zip.tmp") - temporary.unlink(missing_ok=True) - try: - with zipfile.ZipFile(temporary, "w", allowZip64=True) as archive: - for entry in entries: - write_zip_entry( - archive, - f"{root_name}/{entry.relative_path.as_posix()}", - entry.source_path.read_bytes(), - timestamp=timestamp, - executable=entry.executable, - ) - write_zip_entry( - archive, - f"{root_name}/INFO.txt", - info_bytes, - timestamp=timestamp, - ) - write_zip_entry( - archive, - f"{root_name}/PACKAGE_MANIFEST.json", - manifest_bytes, - timestamp=timestamp, - ) - temporary.replace(archive_path) - finally: - temporary.unlink(missing_ok=True) - - checksum = sha256_file(archive_path) - checksum_path.write_text(f"{checksum} {archive_path.name}\n", encoding="utf-8") - return archive_path - - -def parse_arguments(argv: list[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - mode = parser.add_mutually_exclusive_group(required=True) - mode.add_argument("--source", action="store_true", help="Build the clean source package") - mode.add_argument("--full", action="store_true", help="Build the full package with local bulk sources") - parser.add_argument("--repo", help="Repository path; defaults to the current repository") - parser.add_argument( - "--output-dir", - default="dist/releases", - help="Output directory relative to the repository unless absolute", - ) - parser.add_argument("--force", action="store_true", help="Replace an existing archive") - parser.add_argument( - "--allow-dirty", - action="store_true", - help="Allow packaging modified tracked files for diagnostics", - ) - return parser.parse_args(argv) - - -def main(argv: list[str] | None = None) -> int: - arguments = parse_arguments(argv or sys.argv[1:]) - try: - repo = repository_root(arguments.repo) - output_dir = Path(arguments.output_dir).expanduser() - if not output_dir.is_absolute(): - output_dir = repo / output_dir - mode = "full" if arguments.full else "source" - archive = build_archive( - repo=repo, - mode=mode, - output_dir=output_dir.resolve(), - force=arguments.force, - allow_dirty=arguments.allow_dirty, - ) - except (OSError, RuntimeError, ValueError, KeyError) as error: - print(f"ERROR: {error}", file=sys.stderr) - return 1 - - checksum_path = archive.with_suffix(archive.suffix + ".sha256") - print(f"Created: {archive}") - print(f"Checksum: {checksum_path}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) +#!/usr/bin/env python3 +"""Build clean, deterministic MagicAI source and full release archives.""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +from datetime import datetime, timezone +import hashlib +import importlib.util +import json +import os +from pathlib import Path, PurePosixPath +import stat +import subprocess +import sys +import tomllib +import zipfile + + +FULL_SOURCE_FILES = ( + PurePosixPath("sources/scryfall/oracle-cards.json"), + PurePosixPath("sources/scryfall/rulings.json"), +) + +EXCLUDED_PREFIXES = ( + ".git/", + ".venv/", + "backups/", + "backup/", + "build/", + "dist/", + "github-analysis-", + "htmlcov/", + "logs/", + "node_modules/", + "quality-results/", + "reports/", + "resultado_", + "site/", + "test-results/", +) + +EXCLUDED_PARTS = { + "__pycache__", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", +} + + +@dataclass(frozen=True, slots=True) +class ReleaseIdentity: + public_version: str + package_version: str + tag: str + channel: str + codename: str + + +@dataclass(frozen=True, slots=True) +class PackageEntry: + relative_path: PurePosixPath + source_path: Path + executable: bool + + +def run_git(repo: Path, *arguments: str, text: bool = True) -> str | bytes: + result = subprocess.run( + ["git", "-C", str(repo), *arguments], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + text=text, + ) + if result.returncode != 0: + detail = result.stderr.strip() if text else result.stderr.decode(errors="replace").strip() + raise RuntimeError(f"git {' '.join(arguments)} failed: {detail}") + return result.stdout + + +def repository_root(configured: str | None) -> Path: + candidate = Path(configured or Path.cwd()).expanduser().resolve() + output = subprocess.run( + ["git", "-C", str(candidate), "rev-parse", "--show-toplevel"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + if output.returncode != 0: + raise RuntimeError(f"{candidate} is not inside a Git repository") + return Path(output.stdout.strip()).resolve() + + +def load_release_identity(repo: Path) -> ReleaseIdentity: + module_path = repo / "magicai" / "versioning.py" + spec = importlib.util.spec_from_file_location("magicai_release_versioning", module_path) + if spec is None or spec.loader is None: + raise RuntimeError(f"Unable to load {module_path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + pyproject = tomllib.loads((repo / "pyproject.toml").read_text(encoding="utf-8")) + package_version = str(pyproject["project"]["version"]) + expected_package_version = str(module.PACKAGE_FALLBACK_VERSION) + if package_version != expected_package_version: + raise RuntimeError( + "Version mismatch: pyproject.toml declares " + f"{package_version!r}, but magicai/versioning.py declares " + f"{expected_package_version!r}." + ) + + return ReleaseIdentity( + public_version=str(module.PUBLIC_VERSION), + package_version=package_version, + tag=str(module.RELEASE_TAG), + channel=str(module.RELEASE_CHANNEL), + codename=str(module.RELEASE_CODENAME), + ) + + +def tracked_paths(repo: Path) -> list[PurePosixPath]: + raw = run_git(repo, "ls-files", "-z", text=False) + assert isinstance(raw, bytes) + paths = [] + for value in raw.split(b"\0"): + if not value: + continue + paths.append(PurePosixPath(value.decode("utf-8", errors="strict"))) + return sorted(paths, key=str) + + +def should_exclude(path: PurePosixPath) -> bool: + value = path.as_posix() + if any(part in EXCLUDED_PARTS for part in path.parts): + return True + return any(value == prefix.rstrip("/") or value.startswith(prefix) for prefix in EXCLUDED_PREFIXES) + + +def collect_entries(repo: Path, mode: str) -> list[PackageEntry]: + entries: dict[PurePosixPath, PackageEntry] = {} + + for relative in tracked_paths(repo): + if should_exclude(relative): + continue + if mode == "source" and relative in FULL_SOURCE_FILES: + continue + source = repo / relative + if not source.is_file(): + raise RuntimeError(f"Tracked file is missing from the working tree: {relative}") + if source.is_symlink(): + raise RuntimeError(f"Release packages do not include symbolic links: {relative}") + executable = bool(source.stat().st_mode & stat.S_IXUSR) + entries[relative] = PackageEntry(relative, source, executable) + + if mode == "full": + missing = [path for path in FULL_SOURCE_FILES if not (repo / path).is_file()] + if missing: + formatted = ", ".join(str(path) for path in missing) + raise RuntimeError( + "Full package requires local bulk sources. Missing: " + formatted + ) + for relative in FULL_SOURCE_FILES: + source = repo / relative + entries[relative] = PackageEntry(relative, source, False) + + return [entries[path] for path in sorted(entries, key=str)] + + +def sha256_bytes(content: bytes) -> str: + return hashlib.sha256(content).hexdigest() + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def source_epoch(repo: Path) -> int: + configured = os.environ.get("SOURCE_DATE_EPOCH") + if configured: + try: + return max(int(configured), 315532800) + except ValueError as error: + raise RuntimeError("SOURCE_DATE_EPOCH must be an integer") from error + + output = run_git(repo, "log", "-1", "--format=%ct") + assert isinstance(output, str) + return max(int(output.strip()), 315532800) + + +def zip_datetime(epoch: int) -> tuple[int, int, int, int, int, int]: + value = datetime.fromtimestamp(epoch, tz=timezone.utc) + second = value.second - (value.second % 2) + return value.year, value.month, value.day, value.hour, value.minute, second + + +def git_metadata(repo: Path) -> dict[str, str]: + commit = str(run_git(repo, "rev-parse", "HEAD")).strip() + branch = str(run_git(repo, "branch", "--show-current")).strip() or "detached" + status = str(run_git(repo, "status", "--porcelain", "--untracked-files=no")) + return { + "commit": commit, + "branch": branch, + "tracked_worktree": "clean" if not status.strip() else "modified", + } + + +def build_metadata( + *, + repo: Path, + identity: ReleaseIdentity, + mode: str, + entries: list[PackageEntry], + epoch: int, +) -> tuple[bytes, bytes]: + git_info = git_metadata(repo) + files = [] + for entry in entries: + files.append( + { + "path": entry.relative_path.as_posix(), + "size": entry.source_path.stat().st_size, + "sha256": sha256_file(entry.source_path), + } + ) + + generated_at = datetime.fromtimestamp(epoch, tz=timezone.utc).isoformat() + manifest = { + "schema_version": "1.0", + "project": "MagicAI", + "package_mode": mode, + "public_version": identity.public_version, + "package_version": identity.package_version, + "release_tag": identity.tag, + "release_channel": identity.channel, + "release_codename": identity.codename, + "generated_at": generated_at, + "git": git_info, + "files": files, + } + manifest_bytes = ( + json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n" + ).encode("utf-8") + + info = "\n".join( + [ + "MagicAI release package", + "", + f"Public version: {identity.public_version}", + f"Package version: {identity.package_version}", + f"Codename: {identity.codename}", + f"Channel: {identity.channel}", + f"Tag: {identity.tag}", + f"Package mode: {mode}", + f"Git branch: {git_info['branch']}", + f"Git commit: {git_info['commit']}", + f"Tracked worktree: {git_info['tracked_worktree']}", + f"Generated from commit time: {generated_at}", + f"Packaged files: {len(entries)}", + "", + "See PACKAGE_MANIFEST.json for per-file SHA-256 values.", + "", + ] + ).encode("utf-8") + return info, manifest_bytes + + +def write_zip_entry( + archive: zipfile.ZipFile, + archive_name: str, + content: bytes, + *, + timestamp: tuple[int, int, int, int, int, int], + executable: bool = False, +) -> None: + info = zipfile.ZipInfo(archive_name, date_time=timestamp) + info.compress_type = zipfile.ZIP_DEFLATED + info.create_system = 3 + mode = 0o755 if executable else 0o644 + info.external_attr = (stat.S_IFREG | mode) << 16 + archive.writestr(info, content, compresslevel=9) + + +def build_archive( + *, + repo: Path, + mode: str, + output_dir: Path, + force: bool, + allow_dirty: bool = False, +) -> Path: + identity = load_release_identity(repo) + current_git = git_metadata(repo) + if current_git["tracked_worktree"] != "clean" and not allow_dirty: + raise RuntimeError( + "Tracked files are modified. Commit or restore them before packaging, " + "or use --allow-dirty for a diagnostic archive." + ) + entries = collect_entries(repo, mode) + epoch = source_epoch(repo) + timestamp = zip_datetime(epoch) + root_name = f"MagicAI-v{identity.public_version}-{mode}" + output_dir.mkdir(parents=True, exist_ok=True) + archive_path = output_dir / f"{root_name}.zip" + checksum_path = archive_path.with_suffix(archive_path.suffix + ".sha256") + + if (archive_path.exists() or checksum_path.exists()) and not force: + raise RuntimeError( + f"Output already exists: {archive_path}. Use --force to replace it." + ) + + info_bytes, manifest_bytes = build_metadata( + repo=repo, + identity=identity, + mode=mode, + entries=entries, + epoch=epoch, + ) + + temporary = archive_path.with_suffix(".zip.tmp") + temporary.unlink(missing_ok=True) + try: + with zipfile.ZipFile(temporary, "w", allowZip64=True) as archive: + for entry in entries: + write_zip_entry( + archive, + f"{root_name}/{entry.relative_path.as_posix()}", + entry.source_path.read_bytes(), + timestamp=timestamp, + executable=entry.executable, + ) + write_zip_entry( + archive, + f"{root_name}/INFO.txt", + info_bytes, + timestamp=timestamp, + ) + write_zip_entry( + archive, + f"{root_name}/PACKAGE_MANIFEST.json", + manifest_bytes, + timestamp=timestamp, + ) + temporary.replace(archive_path) + finally: + temporary.unlink(missing_ok=True) + + checksum = sha256_file(archive_path) + checksum_path.write_text(f"{checksum} {archive_path.name}\n", encoding="utf-8") + return archive_path + + +def parse_arguments(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument("--source", action="store_true", help="Build the clean source package") + mode.add_argument("--full", action="store_true", help="Build the full package with local bulk sources") + parser.add_argument("--repo", help="Repository path; defaults to the current repository") + parser.add_argument( + "--output-dir", + default="dist/releases", + help="Output directory relative to the repository unless absolute", + ) + parser.add_argument("--force", action="store_true", help="Replace an existing archive") + parser.add_argument( + "--allow-dirty", + action="store_true", + help="Allow packaging modified tracked files for diagnostics", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + arguments = parse_arguments(argv or sys.argv[1:]) + try: + repo = repository_root(arguments.repo) + output_dir = Path(arguments.output_dir).expanduser() + if not output_dir.is_absolute(): + output_dir = repo / output_dir + mode = "full" if arguments.full else "source" + archive = build_archive( + repo=repo, + mode=mode, + output_dir=output_dir.resolve(), + force=arguments.force, + allow_dirty=arguments.allow_dirty, + ) + except (OSError, RuntimeError, ValueError, KeyError) as error: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + + checksum_path = archive.with_suffix(archive.suffix + ".sha256") + print(f"Created: {archive}") + print(f"Checksum: {checksum_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/update_scryfall_symbology.py b/scripts/update_scryfall_symbology.py index 7d7d920..e8d6e73 100644 --- a/scripts/update_scryfall_symbology.py +++ b/scripts/update_scryfall_symbology.py @@ -1,61 +1,61 @@ -import json -import urllib.request -from pathlib import Path - - -PROJECT_ROOT = Path(__file__).resolve().parent.parent - -OUTPUT_FILE = ( - PROJECT_ROOT - / "sources" - / "scryfall" - / "symbology.json" -) - -SCRYFALL_SYMBOLOGY_URL = "https://api.scryfall.com/symbology" - - -def main(): - - OUTPUT_FILE.parent.mkdir( - parents=True, - exist_ok=True, - ) - - request = urllib.request.Request( - SCRYFALL_SYMBOLOGY_URL, - headers={ - "User-Agent": "MagicAI/0.1.1-beta", - "Accept": "application/json", - }, - ) - - with urllib.request.urlopen(request, timeout=30) as response: - - payload = json.loads( - response.read().decode("utf-8") - ) - - OUTPUT_FILE.write_text( - json.dumps( - payload, - ensure_ascii=False, - indent=2, - ), - encoding="utf-8", - ) - - count = len( - payload.get( - "data", - [], - ) - ) - - print(f"Wrote {OUTPUT_FILE}") - print(f"Symbols: {count}") - - -if __name__ == "__main__": - +import json +import urllib.request +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent + +OUTPUT_FILE = ( + PROJECT_ROOT + / "sources" + / "scryfall" + / "symbology.json" +) + +SCRYFALL_SYMBOLOGY_URL = "https://api.scryfall.com/symbology" + + +def main(): + + OUTPUT_FILE.parent.mkdir( + parents=True, + exist_ok=True, + ) + + request = urllib.request.Request( + SCRYFALL_SYMBOLOGY_URL, + headers={ + "User-Agent": "MagicAI/0.1.1-beta", + "Accept": "application/json", + }, + ) + + with urllib.request.urlopen(request, timeout=30) as response: + + payload = json.loads( + response.read().decode("utf-8") + ) + + OUTPUT_FILE.write_text( + json.dumps( + payload, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + + count = len( + payload.get( + "data", + [], + ) + ) + + print(f"Wrote {OUTPUT_FILE}") + print(f"Symbols: {count}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/api/api_contract_metadata_test.py b/tests/api/api_contract_metadata_test.py index 5e681fb..f615198 100644 --- a/tests/api/api_contract_metadata_test.py +++ b/tests/api/api_contract_metadata_test.py @@ -1,90 +1,90 @@ -from magicai.api import routes -from magicai.api.health import ServiceProbe, build_health_payload -from magicai.sources.health import SourceHealth, SourceProbe -from magicai.versioning import ( - API_CONTRACT_VERSION, - JUDGE_RESULT_SCHEMA_VERSION, - PUBLIC_VERSION, - RELEASE_CODENAME, - RELEASE_TAG, -) - - -def _source_health() -> SourceHealth: - probes = { - "scryfall_oracle": SourceProbe( - name="scryfall_oracle", - status="available", - required=True, - ), - "comprehensive_rules": SourceProbe( - name="comprehensive_rules", - status="available", - required=True, - ), - "scryfall_symbology": SourceProbe( - name="scryfall_symbology", - status="available", - required=False, - ), - "scryfall_rulings": SourceProbe( - name="scryfall_rulings", - status="available", - required=False, - ), - } - return SourceHealth( - status="ready", - ready=True, - complete=True, - sources=probes, - ) - - -def test_meta_exposes_stable_contract_values() -> None: - payload = routes.meta() - - assert payload["project_version"] == PUBLIC_VERSION - assert payload["release_codename"] == RELEASE_CODENAME - assert payload["release_tag"] == RELEASE_TAG - assert payload["api_contract_version"] == API_CONTRACT_VERSION - assert payload["judge_result_schema_version"] == JUDGE_RESULT_SCHEMA_VERSION - assert "answered" in payload["judge_statuses"] - assert "deterministic_rulings" in payload["judge_origins"] - assert payload["authority"] == "judge" - - -def test_health_payload_distinguishes_ready_and_full_service() -> None: - payload = build_health_payload( - source_health=_source_health(), - ollama_probe=ServiceProbe( - status="unavailable", - available=False, - detail="test", - model="qwen3:8b", - ), - ) - - assert payload["project_version"] == PUBLIC_VERSION - assert payload["release_codename"] == RELEASE_CODENAME - assert payload["release_tag"] == RELEASE_TAG - assert payload["status"] == "degraded" - assert payload["ready"] is True - assert payload["full_service"] is False - assert payload["sources"]["ready"] is True - - -def main() -> int: - tests = [ - test_meta_exposes_stable_contract_values, - test_health_payload_distinguishes_ready_and_full_service, - ] - for test in tests: - test() - print(f"OK: {test.__name__}") - print(f"API metadata tests: {len(tests)}/{len(tests)}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) +from magicai.api import routes +from magicai.api.health import ServiceProbe, build_health_payload +from magicai.sources.health import SourceHealth, SourceProbe +from magicai.versioning import ( + API_CONTRACT_VERSION, + JUDGE_RESULT_SCHEMA_VERSION, + PUBLIC_VERSION, + RELEASE_CODENAME, + RELEASE_TAG, +) + + +def _source_health() -> SourceHealth: + probes = { + "scryfall_oracle": SourceProbe( + name="scryfall_oracle", + status="available", + required=True, + ), + "comprehensive_rules": SourceProbe( + name="comprehensive_rules", + status="available", + required=True, + ), + "scryfall_symbology": SourceProbe( + name="scryfall_symbology", + status="available", + required=False, + ), + "scryfall_rulings": SourceProbe( + name="scryfall_rulings", + status="available", + required=False, + ), + } + return SourceHealth( + status="ready", + ready=True, + complete=True, + sources=probes, + ) + + +def test_meta_exposes_stable_contract_values() -> None: + payload = routes.meta() + + assert payload["project_version"] == PUBLIC_VERSION + assert payload["release_codename"] == RELEASE_CODENAME + assert payload["release_tag"] == RELEASE_TAG + assert payload["api_contract_version"] == API_CONTRACT_VERSION + assert payload["judge_result_schema_version"] == JUDGE_RESULT_SCHEMA_VERSION + assert "answered" in payload["judge_statuses"] + assert "deterministic_rulings" in payload["judge_origins"] + assert payload["authority"] == "judge" + + +def test_health_payload_distinguishes_ready_and_full_service() -> None: + payload = build_health_payload( + source_health=_source_health(), + ollama_probe=ServiceProbe( + status="unavailable", + available=False, + detail="test", + model="qwen3:8b", + ), + ) + + assert payload["project_version"] == PUBLIC_VERSION + assert payload["release_codename"] == RELEASE_CODENAME + assert payload["release_tag"] == RELEASE_TAG + assert payload["status"] == "degraded" + assert payload["ready"] is True + assert payload["full_service"] is False + assert payload["sources"]["ready"] is True + + +def main() -> int: + tests = [ + test_meta_exposes_stable_contract_values, + test_health_payload_distinguishes_ready_and_full_service, + ] + for test in tests: + test() + print(f"OK: {test.__name__}") + print(f"API metadata tests: {len(tests)}/{len(tests)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/api/tactician_api_contract_test.py b/tests/api/tactician_api_contract_test.py index c2f73aa..7fd5c76 100644 --- a/tests/api/tactician_api_contract_test.py +++ b/tests/api/tactician_api_contract_test.py @@ -1,79 +1,79 @@ -from magicai.api import routes -from magicai.api.schemas import TacticianAskResponse -from magicai.versioning import ( - NEXT_BETA_CODENAME, - NEXT_BETA_VERSION, - PUBLIC_VERSION, - RELEASE_CHANNEL, - RELEASE_CODENAME, - RELEASE_TAG, - TACTICIAN_RESULT_SCHEMA_VERSION, - V1_CODENAME, -) - - -def test_meta_exposes_tactician_profile() -> None: - payload = routes.meta() - assert "tactician" in payload["profiles"] - assert payload["project_version"] == PUBLIC_VERSION - assert payload["release_channel"] == RELEASE_CHANNEL - assert payload["release_codename"] == RELEASE_CODENAME - assert payload["release_tag"] == RELEASE_TAG - assert payload["tactician_result_schema_version"] == TACTICIAN_RESULT_SCHEMA_VERSION - assert payload["next_beta_version"] == NEXT_BETA_VERSION - assert payload["next_beta_codename"] == NEXT_BETA_CODENAME - assert payload["v1_codename"] == V1_CODENAME - capabilities = {item["name"]: item for item in payload["judge_capabilities"]} - assert capabilities["oracle_lookup"]["status"] == "available" - assert capabilities["spellbook_search"]["status"] == "planned" - assert capabilities["strategic_statistics"]["status"] == "permission_required" - - -def test_tactician_response_remains_judge_evidence_compatible() -> None: - response = TacticianAskResponse( - schema_version=TACTICIAN_RESULT_SCHEMA_VERSION, - answer="Sinergia validada.", - session_id="session", - question="¿Hay sinergia?", - status="answered", - origin="tactician_strategy", - confidence="medium", - authority="tactician", - cards=[], - rules=[], - authority_trace=[ - "judge:factual_evidence", - "tactician:strategic_interpretation", - "judge:source_gateway", - ], - strategy_intent="combo_detection", - combo_classification="infinite_combo", - combo_steps=["Repeat the loop."], - outcomes=["Arbitrarily large mana."], - inherited_cards=["Young Wolf"], - judge_queries=[{"sequence": 1, "purpose": "factual_evidence"}], - judge_result={"authority": "judge"}, - ) - payload = response.model_dump() - assert payload["authority"] == "tactician" - assert payload["judge_result"]["authority"] == "judge" - assert payload["authority_trace"][0].startswith("judge:") - assert payload["strategy_intent"] == "combo_detection" - assert payload["combo_classification"] == "infinite_combo" - assert payload["inherited_cards"] == ["Young Wolf"] - - -def main() -> int: - tests = [ - test_meta_exposes_tactician_profile, - test_tactician_response_remains_judge_evidence_compatible, - ] - for test in tests: - test() - print(f"OK: {test.__name__}") - print(f"Tactician API tests: {len(tests)}/{len(tests)}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) +from magicai.api import routes +from magicai.api.schemas import TacticianAskResponse +from magicai.versioning import ( + NEXT_BETA_CODENAME, + NEXT_BETA_VERSION, + PUBLIC_VERSION, + RELEASE_CHANNEL, + RELEASE_CODENAME, + RELEASE_TAG, + TACTICIAN_RESULT_SCHEMA_VERSION, + V1_CODENAME, +) + + +def test_meta_exposes_tactician_profile() -> None: + payload = routes.meta() + assert "tactician" in payload["profiles"] + assert payload["project_version"] == PUBLIC_VERSION + assert payload["release_channel"] == RELEASE_CHANNEL + assert payload["release_codename"] == RELEASE_CODENAME + assert payload["release_tag"] == RELEASE_TAG + assert payload["tactician_result_schema_version"] == TACTICIAN_RESULT_SCHEMA_VERSION + assert payload["next_beta_version"] == NEXT_BETA_VERSION + assert payload["next_beta_codename"] == NEXT_BETA_CODENAME + assert payload["v1_codename"] == V1_CODENAME + capabilities = {item["name"]: item for item in payload["judge_capabilities"]} + assert capabilities["oracle_lookup"]["status"] == "available" + assert capabilities["spellbook_search"]["status"] == "planned" + assert capabilities["strategic_statistics"]["status"] == "permission_required" + + +def test_tactician_response_remains_judge_evidence_compatible() -> None: + response = TacticianAskResponse( + schema_version=TACTICIAN_RESULT_SCHEMA_VERSION, + answer="Sinergia validada.", + session_id="session", + question="¿Hay sinergia?", + status="answered", + origin="tactician_strategy", + confidence="medium", + authority="tactician", + cards=[], + rules=[], + authority_trace=[ + "judge:factual_evidence", + "tactician:strategic_interpretation", + "judge:source_gateway", + ], + strategy_intent="combo_detection", + combo_classification="infinite_combo", + combo_steps=["Repeat the loop."], + outcomes=["Arbitrarily large mana."], + inherited_cards=["Young Wolf"], + judge_queries=[{"sequence": 1, "purpose": "factual_evidence"}], + judge_result={"authority": "judge"}, + ) + payload = response.model_dump() + assert payload["authority"] == "tactician" + assert payload["judge_result"]["authority"] == "judge" + assert payload["authority_trace"][0].startswith("judge:") + assert payload["strategy_intent"] == "combo_detection" + assert payload["combo_classification"] == "infinite_combo" + assert payload["inherited_cards"] == ["Young Wolf"] + + +def main() -> int: + tests = [ + test_meta_exposes_tactician_profile, + test_tactician_response_remains_judge_evidence_compatible, + ] + for test in tests: + test() + print(f"OK: {test.__name__}") + print(f"Tactician API tests: {len(tests)}/{len(tests)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/repository/__init__.py b/tests/repository/__init__.py index 704f7ad..4eefc1e 100644 --- a/tests/repository/__init__.py +++ b/tests/repository/__init__.py @@ -1 +1 @@ -"""Repository health tests.""" +"""Repository health tests.""" diff --git a/tests/repository/release_packaging_test.py b/tests/repository/release_packaging_test.py index f3b5924..57031eb 100644 --- a/tests/repository/release_packaging_test.py +++ b/tests/repository/release_packaging_test.py @@ -1,120 +1,120 @@ -from __future__ import annotations - -import importlib.util -from pathlib import Path -import shutil -import subprocess -import sys -import tempfile -import zipfile - - -ROOT = Path(__file__).resolve().parents[2] -SCRIPT = ROOT / "scripts" / "package_release.py" - - -def load_packager(): - spec = importlib.util.spec_from_file_location("magicai_package_release", SCRIPT) - assert spec is not None and spec.loader is not None - module = importlib.util.module_from_spec(spec) - sys.modules[spec.name] = module - spec.loader.exec_module(module) - return module - - -def run(*arguments: str, cwd: Path) -> None: - subprocess.run(arguments, cwd=cwd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - - -def build_fixture(root: Path) -> None: - (root / "magicai").mkdir(parents=True) - (root / "sources" / "scryfall").mkdir(parents=True) - (root / "logs").mkdir(parents=True) - - shutil.copy2(ROOT / "magicai" / "versioning.py", root / "magicai" / "versioning.py") - shutil.copy2(ROOT / "pyproject.toml", root / "pyproject.toml") - (root / "README.md").write_text("tracked\n", encoding="utf-8") - (root / "tracked.txt").write_text("tracked content\n", encoding="utf-8") - (root / "private.txt").write_text("untracked private content\n", encoding="utf-8") - (root / "logs" / "tracked.log").write_text("must not ship\n", encoding="utf-8") - (root / "sources" / "scryfall" / "symbology.json").write_text("{}\n", encoding="utf-8") - (root / "sources" / "scryfall" / "oracle-cards.json").write_text("[{\"name\":\"A\"}]\n", encoding="utf-8") - (root / "sources" / "scryfall" / "rulings.json").write_text("[]\n", encoding="utf-8") - - run("git", "init", "-q", cwd=root) - run("git", "config", "user.name", "MagicAI Test", cwd=root) - run("git", "config", "user.email", "test@example.invalid", cwd=root) - run( - "git", - "add", - "README.md", - "tracked.txt", - "logs/tracked.log", - "pyproject.toml", - "magicai/versioning.py", - "sources/scryfall/symbology.json", - cwd=root, - ) - run("git", "commit", "-q", "-m", "fixture", cwd=root) - - -def archive_names(path: Path) -> set[str]: - with zipfile.ZipFile(path) as archive: - return set(archive.namelist()) - - -def test_source_and_full_packages_are_clean_and_deterministic() -> None: - packager = load_packager() - with tempfile.TemporaryDirectory(prefix="magicai-package-test-") as directory: - repo = Path(directory) / "repo" - repo.mkdir() - build_fixture(repo) - - first = Path(directory) / "first" - second = Path(directory) / "second" - source_a = packager.build_archive(repo=repo, mode="source", output_dir=first, force=False) - source_b = packager.build_archive(repo=repo, mode="source", output_dir=second, force=False) - full = packager.build_archive(repo=repo, mode="full", output_dir=first, force=False) - - source_root = "MagicAI-v0.1.1-beta-source/" - full_root = "MagicAI-v0.1.1-beta-full/" - source_names = archive_names(source_a) - full_names = archive_names(full) - - assert source_root + "README.md" in source_names - assert source_root + "tracked.txt" in source_names - assert source_root + "sources/scryfall/symbology.json" in source_names - assert source_root + "INFO.txt" in source_names - assert source_root + "PACKAGE_MANIFEST.json" in source_names - assert source_root + "private.txt" not in source_names - assert source_root + "logs/tracked.log" not in source_names - assert source_root + "sources/scryfall/oracle-cards.json" not in source_names - assert source_root + "sources/scryfall/rulings.json" not in source_names - - assert full_root + "sources/scryfall/oracle-cards.json" in full_names - assert full_root + "sources/scryfall/rulings.json" in full_names - assert source_a.read_bytes() == source_b.read_bytes() - - (repo / "tracked.txt").write_text("modified\n", encoding="utf-8") - try: - packager.build_archive( - repo=repo, - mode="source", - output_dir=Path(directory) / "dirty", - force=False, - ) - except RuntimeError as error: - assert "Tracked files are modified" in str(error) - else: - raise AssertionError("Dirty tracked files must be rejected by default") - - -def main() -> int: - test_source_and_full_packages_are_clean_and_deterministic() - print("OK: test_source_and_full_packages_are_clean_and_deterministic") - print("Release packaging tests: 1/1") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) +from __future__ import annotations + +import importlib.util +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import zipfile + + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = ROOT / "scripts" / "package_release.py" + + +def load_packager(): + spec = importlib.util.spec_from_file_location("magicai_package_release", SCRIPT) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def run(*arguments: str, cwd: Path) -> None: + subprocess.run(arguments, cwd=cwd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + +def build_fixture(root: Path) -> None: + (root / "magicai").mkdir(parents=True) + (root / "sources" / "scryfall").mkdir(parents=True) + (root / "logs").mkdir(parents=True) + + shutil.copy2(ROOT / "magicai" / "versioning.py", root / "magicai" / "versioning.py") + shutil.copy2(ROOT / "pyproject.toml", root / "pyproject.toml") + (root / "README.md").write_text("tracked\n", encoding="utf-8") + (root / "tracked.txt").write_text("tracked content\n", encoding="utf-8") + (root / "private.txt").write_text("untracked private content\n", encoding="utf-8") + (root / "logs" / "tracked.log").write_text("must not ship\n", encoding="utf-8") + (root / "sources" / "scryfall" / "symbology.json").write_text("{}\n", encoding="utf-8") + (root / "sources" / "scryfall" / "oracle-cards.json").write_text("[{\"name\":\"A\"}]\n", encoding="utf-8") + (root / "sources" / "scryfall" / "rulings.json").write_text("[]\n", encoding="utf-8") + + run("git", "init", "-q", cwd=root) + run("git", "config", "user.name", "MagicAI Test", cwd=root) + run("git", "config", "user.email", "test@example.invalid", cwd=root) + run( + "git", + "add", + "README.md", + "tracked.txt", + "logs/tracked.log", + "pyproject.toml", + "magicai/versioning.py", + "sources/scryfall/symbology.json", + cwd=root, + ) + run("git", "commit", "-q", "-m", "fixture", cwd=root) + + +def archive_names(path: Path) -> set[str]: + with zipfile.ZipFile(path) as archive: + return set(archive.namelist()) + + +def test_source_and_full_packages_are_clean_and_deterministic() -> None: + packager = load_packager() + with tempfile.TemporaryDirectory(prefix="magicai-package-test-") as directory: + repo = Path(directory) / "repo" + repo.mkdir() + build_fixture(repo) + + first = Path(directory) / "first" + second = Path(directory) / "second" + source_a = packager.build_archive(repo=repo, mode="source", output_dir=first, force=False) + source_b = packager.build_archive(repo=repo, mode="source", output_dir=second, force=False) + full = packager.build_archive(repo=repo, mode="full", output_dir=first, force=False) + + source_root = "MagicAI-v0.1.1-beta-source/" + full_root = "MagicAI-v0.1.1-beta-full/" + source_names = archive_names(source_a) + full_names = archive_names(full) + + assert source_root + "README.md" in source_names + assert source_root + "tracked.txt" in source_names + assert source_root + "sources/scryfall/symbology.json" in source_names + assert source_root + "INFO.txt" in source_names + assert source_root + "PACKAGE_MANIFEST.json" in source_names + assert source_root + "private.txt" not in source_names + assert source_root + "logs/tracked.log" not in source_names + assert source_root + "sources/scryfall/oracle-cards.json" not in source_names + assert source_root + "sources/scryfall/rulings.json" not in source_names + + assert full_root + "sources/scryfall/oracle-cards.json" in full_names + assert full_root + "sources/scryfall/rulings.json" in full_names + assert source_a.read_bytes() == source_b.read_bytes() + + (repo / "tracked.txt").write_text("modified\n", encoding="utf-8") + try: + packager.build_archive( + repo=repo, + mode="source", + output_dir=Path(directory) / "dirty", + force=False, + ) + except RuntimeError as error: + assert "Tracked files are modified" in str(error) + else: + raise AssertionError("Dirty tracked files must be rejected by default") + + +def main() -> int: + test_source_and_full_packages_are_clean_and_deterministic() + print("OK: test_source_and_full_packages_are_clean_and_deterministic") + print("Release packaging tests: 1/1") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/repository/repository_health_test.py b/tests/repository/repository_health_test.py index 91926c6..fd82583 100644 --- a/tests/repository/repository_health_test.py +++ b/tests/repository/repository_health_test.py @@ -1,118 +1,118 @@ -from __future__ import annotations - -from pathlib import Path -import re -import tomllib - -from magicai.versioning import ( - NEXT_BETA_CODENAME, - NEXT_BETA_VERSION, - PACKAGE_FALLBACK_VERSION, - PUBLIC_VERSION, - RELEASE_CHANNEL, - RELEASE_CODENAME, - RELEASE_TAG, - V1_CODENAME, -) - - -ROOT = Path(__file__).resolve().parents[2] - - -def test_release_identity_is_consistent() -> None: - pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) - assert pyproject["project"]["version"] == PACKAGE_FALLBACK_VERSION - assert PUBLIC_VERSION == "0.1.1-beta" - assert RELEASE_TAG == "v0.1.1-beta" - assert RELEASE_CHANNEL == "beta" - assert RELEASE_CODENAME == "Force of Will" - assert NEXT_BETA_VERSION == "0.2.0-beta" - assert NEXT_BETA_CODENAME == "Ponder" - assert V1_CODENAME == "NicolAI Bolas" - - -def test_required_community_and_automation_files_exist() -> None: - required = [ - ".github/workflows/ci.yml", - ".github/dependabot.yml", - ".github/PULL_REQUEST_TEMPLATE.md", - "SECURITY.md", - "CODE_OF_CONDUCT.md", - "SUPPORT.md", - "docs/BRANCHING.md", - "docs/RELEASE_PROCESS.md", - "docs/REPOSITORY_HEALTH.md", - "scripts/ci_check.py", - "scripts/package_release.py", - "scripts/export_github_analysis.sh", - ] - missing = [relative for relative in required if not (ROOT / relative).is_file()] - assert not missing, f"Missing repository-health files: {missing}" - - -def test_ci_is_fast_and_does_not_require_bulk_sources_or_ollama() -> None: - workflow = (ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8") - assert "python scripts/ci_check.py" in workflow - assert "download_sources.sh" not in workflow - assert "oracle_exhaustive_test" not in workflow - assert "ollama pull" not in workflow - assert "permissions:\n contents: read" in workflow - - -def test_release_packaging_uses_git_and_ignores_local_exports() -> None: - script = (ROOT / "scripts/package_release.py").read_text(encoding="utf-8") - gitignore = (ROOT / ".gitignore").read_text(encoding="utf-8") - exporter = (ROOT / "scripts/export_github_analysis.sh").read_text(encoding="utf-8") - - assert '"ls-files", "-z"' in script - assert "FULL_SOURCE_FILES" in script - assert "/github-analysis-*/" in gitignore - assert "/dist/releases/" in gitignore - assert "--slurp" not in exporter - assert "if len(parts) < 6" in exporter - assert '--list-file="$OUT/git/tracked_files.txt"' in exporter - - -def test_markdown_is_english_and_personal_letter_is_preserved() -> None: - markdown_files = sorted( - path - for path in ROOT.rglob("*.md") - if not any(part in {"backups", "github-analysis-20260715-025523"} for part in path.parts) - ) - spanish_markers = re.compile( - r"[¿¡]|\b(?:por motivos|si has llegado|nos vemos|me gustaría|mientras mi salud|gracias por dedicar)\b", - flags=re.IGNORECASE, - ) - violations = [] - for path in markdown_files: - content = path.read_text(encoding="utf-8") - if spanish_markers.search(content): - violations.append(str(path.relative_to(ROOT))) - assert not violations, f"Markdown must remain English-only: {violations}" - - readme = (ROOT / "README.md").read_text(encoding="utf-8") - assert "# ❤️ A personal letter" in readme - assert "Due to health reasons" in readme - assert "See you in the next game." in readme - assert "Force of Will" in readme - assert "Ponder" in readme - assert "NicolAI Bolas" in readme - - -def main() -> int: - tests = [ - test_release_identity_is_consistent, - test_required_community_and_automation_files_exist, - test_ci_is_fast_and_does_not_require_bulk_sources_or_ollama, - test_release_packaging_uses_git_and_ignores_local_exports, - test_markdown_is_english_and_personal_letter_is_preserved, - ] - for test in tests: - test() - print(f"OK: {test.__name__}") - print(f"Repository health tests: {len(tests)}/{len(tests)}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) +from __future__ import annotations + +from pathlib import Path +import re +import tomllib + +from magicai.versioning import ( + NEXT_BETA_CODENAME, + NEXT_BETA_VERSION, + PACKAGE_FALLBACK_VERSION, + PUBLIC_VERSION, + RELEASE_CHANNEL, + RELEASE_CODENAME, + RELEASE_TAG, + V1_CODENAME, +) + + +ROOT = Path(__file__).resolve().parents[2] + + +def test_release_identity_is_consistent() -> None: + pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) + assert pyproject["project"]["version"] == PACKAGE_FALLBACK_VERSION + assert PUBLIC_VERSION == "0.1.1-beta" + assert RELEASE_TAG == "v0.1.1-beta" + assert RELEASE_CHANNEL == "beta" + assert RELEASE_CODENAME == "Force of Will" + assert NEXT_BETA_VERSION == "0.2.0-beta" + assert NEXT_BETA_CODENAME == "Ponder" + assert V1_CODENAME == "NicolAI Bolas" + + +def test_required_community_and_automation_files_exist() -> None: + required = [ + ".github/workflows/ci.yml", + ".github/dependabot.yml", + ".github/PULL_REQUEST_TEMPLATE.md", + "SECURITY.md", + "CODE_OF_CONDUCT.md", + "SUPPORT.md", + "docs/BRANCHING.md", + "docs/RELEASE_PROCESS.md", + "docs/REPOSITORY_HEALTH.md", + "scripts/ci_check.py", + "scripts/package_release.py", + "scripts/export_github_analysis.sh", + ] + missing = [relative for relative in required if not (ROOT / relative).is_file()] + assert not missing, f"Missing repository-health files: {missing}" + + +def test_ci_is_fast_and_does_not_require_bulk_sources_or_ollama() -> None: + workflow = (ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8") + assert "python scripts/ci_check.py" in workflow + assert "download_sources.sh" not in workflow + assert "oracle_exhaustive_test" not in workflow + assert "ollama pull" not in workflow + assert "permissions:\n contents: read" in workflow + + +def test_release_packaging_uses_git_and_ignores_local_exports() -> None: + script = (ROOT / "scripts/package_release.py").read_text(encoding="utf-8") + gitignore = (ROOT / ".gitignore").read_text(encoding="utf-8") + exporter = (ROOT / "scripts/export_github_analysis.sh").read_text(encoding="utf-8") + + assert '"ls-files", "-z"' in script + assert "FULL_SOURCE_FILES" in script + assert "/github-analysis-*/" in gitignore + assert "/dist/releases/" in gitignore + assert "--slurp" not in exporter + assert "if len(parts) < 6" in exporter + assert '--list-file="$OUT/git/tracked_files.txt"' in exporter + + +def test_markdown_is_english_and_personal_letter_is_preserved() -> None: + markdown_files = sorted( + path + for path in ROOT.rglob("*.md") + if not any(part in {"backups", "github-analysis-20260715-025523"} for part in path.parts) + ) + spanish_markers = re.compile( + r"[¿¡]|\b(?:por motivos|si has llegado|nos vemos|me gustaría|mientras mi salud|gracias por dedicar)\b", + flags=re.IGNORECASE, + ) + violations = [] + for path in markdown_files: + content = path.read_text(encoding="utf-8") + if spanish_markers.search(content): + violations.append(str(path.relative_to(ROOT))) + assert not violations, f"Markdown must remain English-only: {violations}" + + readme = (ROOT / "README.md").read_text(encoding="utf-8") + assert "# ❤️ A personal letter" in readme + assert "Due to health reasons" in readme + assert "See you in the next game." in readme + assert "Force of Will" in readme + assert "Ponder" in readme + assert "NicolAI Bolas" in readme + + +def main() -> int: + tests = [ + test_release_identity_is_consistent, + test_required_community_and_automation_files_exist, + test_ci_is_fast_and_does_not_require_bulk_sources_or_ollama, + test_release_packaging_uses_git_and_ignores_local_exports, + test_markdown_is_english_and_personal_letter_is_preserved, + ] + for test in tests: + test() + print(f"OK: {test.__name__}") + print(f"Repository health tests: {len(tests)}/{len(tests)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/ui/ui_usability_test.py b/tests/ui/ui_usability_test.py index 804f4f7..25c0c4a 100644 --- a/tests/ui/ui_usability_test.py +++ b/tests/ui/ui_usability_test.py @@ -1,172 +1,172 @@ -from html.parser import HTMLParser -from pathlib import Path - -from magicai.ui.routes import INDEX_FILE, UI_ROOT - - -class ControlCollector(HTMLParser): - def __init__(self) -> None: - super().__init__() - self.attributes_by_id: dict[str, dict[str, str | None]] = {} - - def handle_starttag(self, tag: str, attrs) -> None: - attributes = dict(attrs) - element_id = attributes.get("id") - if element_id: - self.attributes_by_id[element_id] = attributes - - -def read_javascript() -> str: - return (UI_ROOT / "app.js").read_text(encoding="utf-8") - - -def read_stylesheet() -> str: - return (UI_ROOT / "app.css").read_text(encoding="utf-8") - - -def test_disambiguation_candidates_are_interactive_and_persisted() -> None: - javascript = read_javascript() - - assert "function getClarificationCandidates(result)" in javascript - assert 'result?.status !== "needs_clarification"' in javascript - assert "function renderDisambiguationActions(candidates, selectedCandidate = null)" in javascript - assert "function submitClarificationCandidate(candidate)" in javascript - assert 'button.className = "clarification-option"' in javascript - assert 'button.addEventListener("click", () => submitClarificationCandidate(candidate))' in javascript - assert "normalizeCandidates(message.candidates)" in javascript - assert "function markClarificationResolved(candidate)" in javascript - assert "message.selectedCandidate = normalized" in javascript - assert "MAX_DISAMBIGUATION_CANDIDATES = 8" in javascript - - -def test_copy_and_export_controls_use_the_last_structured_result() -> None: - parser = ControlCollector() - parser.feed(INDEX_FILE.read_text(encoding="utf-8")) - javascript = read_javascript() - - for control_id in ( - "copy-answer-button", - "copy-evidence-button", - "export-result-button", - "export-feedback-button", - ): - attributes = parser.attributes_by_id[control_id] - assert attributes.get("type") == "button" - assert "disabled" in attributes - - assert "async function copyLastAnswer()" in javascript - assert "async function copyLastEvidence()" in javascript - assert "function buildEvidenceText(result)" in javascript - assert 'appendEvidenceTextSection(lines, "Versiones de fuentes"' in javascript - assert "function exportLastResult()" in javascript - assert 'new Blob([payload], {type: "application/json;charset=utf-8"})' in javascript - assert "magicai-judge-result-${formatExportTimestamp(new Date())}.json" in javascript - assert "function downloadJson(value, filename)" in javascript - assert "function exportFeedbackCase()" in javascript - assert "magicai-community-feedback-${timestamp}.json" in javascript - assert 'artifact_purpose: "evaluation"' in javascript - assert "training_allowed: false" in javascript - assert "automatic_learning: false" in javascript - assert "automatic_promotion: false" in javascript - assert 'mode: "exploratory"' in javascript - assert 'paraphrased: true' in javascript - assert 'contains_verbatim_quote: false' in javascript - assert 'contains_personal_data: false' in javascript - - -def test_evidence_sections_open_from_actual_content() -> None: - javascript = read_javascript() - stylesheet = read_stylesheet() - - assert "function configureEvidenceSections" in javascript - assert '["cards-section", cards.length, cards.length > 0]' in javascript - assert '["warnings-section", warnings.length, warnings.length > 0]' in javascript - assert 'section.classList.toggle("is-empty", count === 0)' in javascript - assert "STATUS_EXPLANATIONS" in javascript - assert 'oracle.className = "oracle-text"' in javascript - assert 'node.className = "evidence-card rule-card"' in javascript - assert ".evidence-card-header" in stylesheet - assert ".oracle-text" in stylesheet - assert ".evidence-note.is-warnings" in stylesheet - - -def test_copy_fallback_and_rendering_remain_local_and_safe() -> None: - javascript = read_javascript() - - assert "navigator.clipboard?.writeText" in javascript - assert 'document.execCommand("copy")' in javascript - assert 'textarea.className = "clipboard-fallback"' in javascript - assert "textContent" in javascript - assert "innerHTML" not in javascript - assert "eval(" not in javascript - - -def test_quickstart_documents_branches_ui_and_ollama_modes() -> None: - quickstart = Path("docs/QUICKSTART.md").read_text(encoding="utf-8") - readme = Path("README.md").read_text(encoding="utf-8") - - assert "git clone https://github.com/Fartis/MagicAI.git" in quickstart - assert "git clone -b develop https://github.com/Fartis/MagicAI.git" in quickstart - assert "http://127.0.0.1:8000/ui" in quickstart - assert "Ollama on the same machine" in quickstart - assert "Ollama in an existing container" in quickstart - assert "Ollama on another LAN machine" in quickstart - assert "231/231" in readme - assert "do **not** mean" in readme - assert "docs/QUICKSTART.md" in readme - assert "# ❤️ A personal letter" in readme - assert "See you in the next game." in readme - assert "v0.1.1-beta" in readme and "Force of Will" in readme - assert "v0.2.0-beta" in readme and "Ponder" in readme - assert "NicolAI Bolas" in readme - - - - - -def test_strategy_handoff_renders_tactician_result_and_combo_trace() -> None: - javascript = read_javascript() - stylesheet = read_stylesheet() - - assert 'profile: result.authority === "tactician" ? "tactician" : state.profile' in javascript - assert "function renderStrategySummary(result)" in javascript - assert "result.combo_steps || []" in javascript - assert "result.outcomes || []" in javascript - assert '["Intent estratégico", result.strategy_intent || "—"]' in javascript - assert '["Clasificación de combo", result.combo_classification || "—"]' in javascript - assert '["Cartas heredadas", (result.inherited_cards || []).join(" · ") || "—"]' in javascript - assert 'container.className = "strategy-summary"' in javascript - assert ".strategy-summary" in stylesheet - -def test_profile_switch_exposes_judge_and_tactician() -> None: - parser = ControlCollector() - parser.feed(INDEX_FILE.read_text(encoding="utf-8")) - javascript = read_javascript() - - assert "judge-profile-button" in parser.attributes_by_id - assert "tactician-profile-button" in parser.attributes_by_id - assert 'function setProfile(profile)' in javascript - assert 'state.profile === "tactician"' in javascript - assert '"/tactician/ask"' in javascript - assert 'Estratega' in javascript - - -def main() -> int: - tests = [ - test_disambiguation_candidates_are_interactive_and_persisted, - test_copy_and_export_controls_use_the_last_structured_result, - test_evidence_sections_open_from_actual_content, - test_copy_fallback_and_rendering_remain_local_and_safe, - test_strategy_handoff_renders_tactician_result_and_combo_trace, - test_profile_switch_exposes_judge_and_tactician, - test_quickstart_documents_branches_ui_and_ollama_modes, - ] - for test in tests: - test() - print(f"OK: {test.__name__}") - print(f"UI usability tests: {len(tests)}/{len(tests)}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) +from html.parser import HTMLParser +from pathlib import Path + +from magicai.ui.routes import INDEX_FILE, UI_ROOT + + +class ControlCollector(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.attributes_by_id: dict[str, dict[str, str | None]] = {} + + def handle_starttag(self, tag: str, attrs) -> None: + attributes = dict(attrs) + element_id = attributes.get("id") + if element_id: + self.attributes_by_id[element_id] = attributes + + +def read_javascript() -> str: + return (UI_ROOT / "app.js").read_text(encoding="utf-8") + + +def read_stylesheet() -> str: + return (UI_ROOT / "app.css").read_text(encoding="utf-8") + + +def test_disambiguation_candidates_are_interactive_and_persisted() -> None: + javascript = read_javascript() + + assert "function getClarificationCandidates(result)" in javascript + assert 'result?.status !== "needs_clarification"' in javascript + assert "function renderDisambiguationActions(candidates, selectedCandidate = null)" in javascript + assert "function submitClarificationCandidate(candidate)" in javascript + assert 'button.className = "clarification-option"' in javascript + assert 'button.addEventListener("click", () => submitClarificationCandidate(candidate))' in javascript + assert "normalizeCandidates(message.candidates)" in javascript + assert "function markClarificationResolved(candidate)" in javascript + assert "message.selectedCandidate = normalized" in javascript + assert "MAX_DISAMBIGUATION_CANDIDATES = 8" in javascript + + +def test_copy_and_export_controls_use_the_last_structured_result() -> None: + parser = ControlCollector() + parser.feed(INDEX_FILE.read_text(encoding="utf-8")) + javascript = read_javascript() + + for control_id in ( + "copy-answer-button", + "copy-evidence-button", + "export-result-button", + "export-feedback-button", + ): + attributes = parser.attributes_by_id[control_id] + assert attributes.get("type") == "button" + assert "disabled" in attributes + + assert "async function copyLastAnswer()" in javascript + assert "async function copyLastEvidence()" in javascript + assert "function buildEvidenceText(result)" in javascript + assert 'appendEvidenceTextSection(lines, "Versiones de fuentes"' in javascript + assert "function exportLastResult()" in javascript + assert 'new Blob([payload], {type: "application/json;charset=utf-8"})' in javascript + assert "magicai-judge-result-${formatExportTimestamp(new Date())}.json" in javascript + assert "function downloadJson(value, filename)" in javascript + assert "function exportFeedbackCase()" in javascript + assert "magicai-community-feedback-${timestamp}.json" in javascript + assert 'artifact_purpose: "evaluation"' in javascript + assert "training_allowed: false" in javascript + assert "automatic_learning: false" in javascript + assert "automatic_promotion: false" in javascript + assert 'mode: "exploratory"' in javascript + assert 'paraphrased: true' in javascript + assert 'contains_verbatim_quote: false' in javascript + assert 'contains_personal_data: false' in javascript + + +def test_evidence_sections_open_from_actual_content() -> None: + javascript = read_javascript() + stylesheet = read_stylesheet() + + assert "function configureEvidenceSections" in javascript + assert '["cards-section", cards.length, cards.length > 0]' in javascript + assert '["warnings-section", warnings.length, warnings.length > 0]' in javascript + assert 'section.classList.toggle("is-empty", count === 0)' in javascript + assert "STATUS_EXPLANATIONS" in javascript + assert 'oracle.className = "oracle-text"' in javascript + assert 'node.className = "evidence-card rule-card"' in javascript + assert ".evidence-card-header" in stylesheet + assert ".oracle-text" in stylesheet + assert ".evidence-note.is-warnings" in stylesheet + + +def test_copy_fallback_and_rendering_remain_local_and_safe() -> None: + javascript = read_javascript() + + assert "navigator.clipboard?.writeText" in javascript + assert 'document.execCommand("copy")' in javascript + assert 'textarea.className = "clipboard-fallback"' in javascript + assert "textContent" in javascript + assert "innerHTML" not in javascript + assert "eval(" not in javascript + + +def test_quickstart_documents_branches_ui_and_ollama_modes() -> None: + quickstart = Path("docs/QUICKSTART.md").read_text(encoding="utf-8") + readme = Path("README.md").read_text(encoding="utf-8") + + assert "git clone https://github.com/Fartis/MagicAI.git" in quickstart + assert "git clone -b develop https://github.com/Fartis/MagicAI.git" in quickstart + assert "http://127.0.0.1:8000/ui" in quickstart + assert "Ollama on the same machine" in quickstart + assert "Ollama in an existing container" in quickstart + assert "Ollama on another LAN machine" in quickstart + assert "231/231" in readme + assert "do **not** mean" in readme + assert "docs/QUICKSTART.md" in readme + assert "# ❤️ A personal letter" in readme + assert "See you in the next game." in readme + assert "v0.1.1-beta" in readme and "Force of Will" in readme + assert "v0.2.0-beta" in readme and "Ponder" in readme + assert "NicolAI Bolas" in readme + + + + + +def test_strategy_handoff_renders_tactician_result_and_combo_trace() -> None: + javascript = read_javascript() + stylesheet = read_stylesheet() + + assert 'profile: result.authority === "tactician" ? "tactician" : state.profile' in javascript + assert "function renderStrategySummary(result)" in javascript + assert "result.combo_steps || []" in javascript + assert "result.outcomes || []" in javascript + assert '["Intent estratégico", result.strategy_intent || "—"]' in javascript + assert '["Clasificación de combo", result.combo_classification || "—"]' in javascript + assert '["Cartas heredadas", (result.inherited_cards || []).join(" · ") || "—"]' in javascript + assert 'container.className = "strategy-summary"' in javascript + assert ".strategy-summary" in stylesheet + +def test_profile_switch_exposes_judge_and_tactician() -> None: + parser = ControlCollector() + parser.feed(INDEX_FILE.read_text(encoding="utf-8")) + javascript = read_javascript() + + assert "judge-profile-button" in parser.attributes_by_id + assert "tactician-profile-button" in parser.attributes_by_id + assert 'function setProfile(profile)' in javascript + assert 'state.profile === "tactician"' in javascript + assert '"/tactician/ask"' in javascript + assert 'Estratega' in javascript + + +def main() -> int: + tests = [ + test_disambiguation_candidates_are_interactive_and_persisted, + test_copy_and_export_controls_use_the_last_structured_result, + test_evidence_sections_open_from_actual_content, + test_copy_fallback_and_rendering_remain_local_and_safe, + test_strategy_handoff_renders_tactician_result_and_combo_trace, + test_profile_switch_exposes_judge_and_tactician, + test_quickstart_documents_branches_ui_and_ollama_modes, + ] + for test in tests: + test() + print(f"OK: {test.__name__}") + print(f"UI usability tests: {len(tests)}/{len(tests)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/uv.lock b/uv.lock index 9e66b1a..b25cd64 100644 --- a/uv.lock +++ b/uv.lock @@ -1,339 +1,339 @@ -version = 1 -revision = 3 -requires-python = ">=3.12" - -[[package]] -name = "annotated-doc" -version = "0.0.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, -] - -[[package]] -name = "annotated-types" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, -] - -[[package]] -name = "anyio" -version = "4.14.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, -] - -[[package]] -name = "certifi" -version = "2026.6.17" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, - { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, - { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, - { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, - { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, - { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, - { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, - { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, - { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, - { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, - { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, - { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, - { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, - { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, - { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, - { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, - { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, - { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, - { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, - { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, - { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, - { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, - { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, - { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, - { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, - { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, - { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, - { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, - { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, - { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, - { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, - { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, - { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, - { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, - { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, - { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, - { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, - { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, - { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, - { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, - { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, - { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, - { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, - { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, - { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, - { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, - { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, - { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, - { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, -] - -[[package]] -name = "click" -version = "8.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, -] - -[[package]] -name = "fastapi" -version = "0.139.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-doc" }, - { name = "pydantic" }, - { name = "starlette" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d3/af/a5f50ccfa659ec1802cb4ca842c23f06d906a8cc9aef6016a2caeea3d4ed/fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145", size = 423016, upload-time = "2026-07-01T16:35:33.436Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/7c/8e3c6ad324ea5cb36604fc3f968554887891c316d9dfde57761611d907ad/fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189", size = 130339, upload-time = "2026-07-01T16:35:32.19Z" }, -] - -[[package]] -name = "h11" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, -] - -[[package]] -name = "idna" -version = "3.18" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, -] - -[[package]] -name = "magicai" -version = "0.1.1b0" -source = { editable = "." } -dependencies = [ - { name = "fastapi" }, - { name = "pydantic" }, - { name = "requests" }, - { name = "uvicorn" }, -] - -[package.metadata] -requires-dist = [ - { name = "fastapi", specifier = ">=0.139.0,<0.140.0" }, - { name = "pydantic", specifier = ">=2.13.4,<3.0.0" }, - { name = "requests", specifier = ">=2.34.2,<3.0.0" }, - { name = "uvicorn", specifier = ">=0.50.2,<0.52.0" }, -] - -[[package]] -name = "pydantic" -version = "2.13.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, -] - -[[package]] -name = "pydantic-core" -version = "2.46.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, - { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, - { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, - { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, - { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, - { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, - { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, - { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, - { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, - { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, - { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, - { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, - { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, - { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, - { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, - { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, - { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, - { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, - { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, - { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, - { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, - { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, - { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, - { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, - { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, - { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, - { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, - { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, - { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, - { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, - { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, - { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, - { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, - { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, - { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, - { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, - { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, - { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, - { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, - { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, - { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, - { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, - { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, - { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, - { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, - { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, - { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, - { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, - { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, - { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, - { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, - { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, - { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, - { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, - { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, - { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, - { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, - { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, - { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, - { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, - { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, - { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, -] - -[[package]] -name = "requests" -version = "2.34.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, -] - -[[package]] -name = "starlette" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, -] - -[[package]] -name = "typing-extensions" -version = "4.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, -] - -[[package]] -name = "typing-inspection" -version = "0.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, -] - -[[package]] -name = "urllib3" -version = "2.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, -] - -[[package]] -name = "uvicorn" -version = "0.51.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, -] +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "fastapi" +version = "0.139.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/af/a5f50ccfa659ec1802cb4ca842c23f06d906a8cc9aef6016a2caeea3d4ed/fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145", size = 423016, upload-time = "2026-07-01T16:35:33.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/7c/8e3c6ad324ea5cb36604fc3f968554887891c316d9dfde57761611d907ad/fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189", size = 130339, upload-time = "2026-07-01T16:35:32.19Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "magicai" +version = "0.1.1b0" +source = { editable = "." } +dependencies = [ + { name = "fastapi" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "uvicorn" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.139.0,<0.140.0" }, + { name = "pydantic", specifier = ">=2.13.4,<3.0.0" }, + { name = "requests", specifier = ">=2.34.2,<3.0.0" }, + { name = "uvicorn", specifier = ">=0.50.2,<0.52.0" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.51.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, +] From 7326f29c5abe9aae0f187b828af8c7391547c14f Mon Sep 17 00:00:00 2001 From: Manuel David Villalba Escamilla Date: Wed, 15 Jul 2026 11:18:55 +0200 Subject: [PATCH 2/7] fix: enforce LF line endings for repository scripts --- .gitattributes | 11 + scripts/ci_check.py | 160 +-- scripts/export_github_analysis.sh | 1596 +++++++++++++------------- scripts/package_release.py | 808 ++++++------- scripts/update_scryfall_symbology.py | 120 +- 5 files changed, 1353 insertions(+), 1342 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..559a390 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +* text=auto + +*.sh text eol=lf +*.py text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.md text eol=lf +*.toml text eol=lf +*.html text eol=lf +*.css text eol=lf +*.js text eol=lf diff --git a/scripts/ci_check.py b/scripts/ci_check.py index 67c013e..892b14e 100644 --- a/scripts/ci_check.py +++ b/scripts/ci_check.py @@ -1,80 +1,80 @@ -#!/usr/bin/env python3 -"""Run MagicAI's fast, source-independent pull request checks.""" - -from __future__ import annotations - -import os -from pathlib import Path -import shutil -import subprocess -import sys -import tempfile - - -ROOT = Path(__file__).resolve().parents[1] - -TEST_MODULES = ( - "tests.repository.repository_health_test", - "tests.repository.release_packaging_test", - "tests.tactician.tactician_reviewer_test", - "tests.tactician.tactician_strategy_test", - "tests.tactician.tactician_conversation_handoff_test", - "tests.tactician.tactician_core_test", - "tests.api.api_contract_metadata_test", - "tests.api.tactician_api_contract_test", - "tests.api.tactician_auto_handoff_test", - "tests.api.judge_result_schema_test", - "tests.api.api_error_contract_test", - "tests.api.ui_routes_test", - "tests.conversation.conversation_repository_test", - "tests.validation.strategy_boundary_test", - "tests.validation.oracle_derived_undying_test", - "tests.validation.rule_renderer_test", - "tests.ui.ui_assets_test", - "tests.ui.ui_usability_test", -) - - -def run(command: list[str], *, environment: dict[str, str]) -> None: - print("+", " ".join(command), flush=True) - subprocess.run(command, cwd=ROOT, env=environment, check=True) - - -def main() -> int: - environment = os.environ.copy() - environment["PYTHONPATH"] = str(ROOT) - environment.setdefault("MAGICAI_QUIET_EVALUATION", "1") - - with tempfile.TemporaryDirectory(prefix="magicai-ci-") as directory: - environment.setdefault( - "MAGICAI_CONVERSATION_DB", - str(Path(directory) / "conversations.sqlite3"), - ) - - run( - [sys.executable, "-m", "compileall", "-q", "magicai", "tests", "scripts"], - environment=environment, - ) - - if (ROOT / ".git").exists(): - run(["git", "diff", "--check"], environment=environment) - - bash = shutil.which("bash") - if bash: - for script in sorted((ROOT / "scripts").glob("*.sh")): - run([bash, "-n", str(script.relative_to(ROOT))], environment=environment) - - node = shutil.which("node") - if node: - run([node, "--check", "magicai/ui/static/app.js"], environment=environment) - - for module in TEST_MODULES: - run([sys.executable, "-m", module], environment=environment) - - print() - print(f"MagicAI focused CI: {len(TEST_MODULES)}/{len(TEST_MODULES)} modules passed") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) +#!/usr/bin/env python3 +"""Run MagicAI's fast, source-independent pull request checks.""" + +from __future__ import annotations + +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile + + +ROOT = Path(__file__).resolve().parents[1] + +TEST_MODULES = ( + "tests.repository.repository_health_test", + "tests.repository.release_packaging_test", + "tests.tactician.tactician_reviewer_test", + "tests.tactician.tactician_strategy_test", + "tests.tactician.tactician_conversation_handoff_test", + "tests.tactician.tactician_core_test", + "tests.api.api_contract_metadata_test", + "tests.api.tactician_api_contract_test", + "tests.api.tactician_auto_handoff_test", + "tests.api.judge_result_schema_test", + "tests.api.api_error_contract_test", + "tests.api.ui_routes_test", + "tests.conversation.conversation_repository_test", + "tests.validation.strategy_boundary_test", + "tests.validation.oracle_derived_undying_test", + "tests.validation.rule_renderer_test", + "tests.ui.ui_assets_test", + "tests.ui.ui_usability_test", +) + + +def run(command: list[str], *, environment: dict[str, str]) -> None: + print("+", " ".join(command), flush=True) + subprocess.run(command, cwd=ROOT, env=environment, check=True) + + +def main() -> int: + environment = os.environ.copy() + environment["PYTHONPATH"] = str(ROOT) + environment.setdefault("MAGICAI_QUIET_EVALUATION", "1") + + with tempfile.TemporaryDirectory(prefix="magicai-ci-") as directory: + environment.setdefault( + "MAGICAI_CONVERSATION_DB", + str(Path(directory) / "conversations.sqlite3"), + ) + + run( + [sys.executable, "-m", "compileall", "-q", "magicai", "tests", "scripts"], + environment=environment, + ) + + if (ROOT / ".git").exists(): + run(["git", "diff", "--check"], environment=environment) + + bash = shutil.which("bash") + if bash: + for script in sorted((ROOT / "scripts").glob("*.sh")): + run([bash, "-n", str(script.relative_to(ROOT))], environment=environment) + + node = shutil.which("node") + if node: + run([node, "--check", "magicai/ui/static/app.js"], environment=environment) + + for module in TEST_MODULES: + run([sys.executable, "-m", module], environment=environment) + + print() + print(f"MagicAI focused CI: {len(TEST_MODULES)}/{len(TEST_MODULES)} modules passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/export_github_analysis.sh b/scripts/export_github_analysis.sh index fe65251..786cc60 100644 --- a/scripts/export_github_analysis.sh +++ b/scripts/export_github_analysis.sh @@ -1,798 +1,798 @@ -#!/usr/bin/env bash - -set -Eeuo pipefail - -command -v gh >/dev/null || { - echo "Error: GitHub CLI (gh) is required." - exit 1 -} - -command -v jq >/dev/null || { - echo "Error: jq is required." - exit 1 -} - -command -v python3 >/dev/null || { - echo "Error: python3 is required." - exit 1 -} - -git rev-parse --is-inside-work-tree >/dev/null 2>&1 || { - echo "Error: run this script inside the Git repository." - exit 1 -} - -gh auth status >/dev/null 2>&1 || { - echo "Error: authenticate first with: gh auth login" - exit 1 -} - -TIMESTAMP="$(date +%Y%m%d-%H%M%S)" -OUT="github-analysis-${TIMESTAMP}" - -mkdir -p \ - "$OUT/api/stats" \ - "$OUT/api/traffic" \ - "$OUT/api/pr_details" \ - "$OUT/git" \ - "$OUT/csv" - -ERROR_LOG="$OUT/api_errors.log" -touch "$ERROR_LOG" - -echo "Exporting repository analysis to: $OUT" - -api_object() { - local endpoint="$1" - local output="$2" - - if ! gh api "$endpoint" > "$output" 2>>"$ERROR_LOG"; then - echo '{"available":false,"reason":"API request failed or permission unavailable"}' > "$output" - fi -} - -api_array() { - local endpoint="$1" - local output="$2" - - if ! gh api --paginate "$endpoint" --jq '.[]' 2>>"$ERROR_LOG" | - jq -s '.' > "$output"; then - echo '[]' > "$output" - fi -} - -stat_api() { - local endpoint="$1" - local output="$2" - local temporary="${output}.tmp" - - for attempt in 1 2 3 4 5; do - rm -f "$temporary" - - gh api "$endpoint" > "$temporary" 2>>"$ERROR_LOG" || true - - if [[ -s "$temporary" ]]; then - mv "$temporary" "$output" - return 0 - fi - - sleep 3 - done - - rm -f "$temporary" - - cat > "$output" < "$OUT/api/contributors.json" - -rm -f "$OUT/api/contributors_raw.json" - -# --------------------------------------------------------------------------- -# Branches, tags, and releases -# --------------------------------------------------------------------------- - -api_array "repos/{owner}/{repo}/branches?per_page=100" \ - "$OUT/api/branches_raw.json" - -jq ' -map({ - name, - sha: .commit.sha, - protected -}) -' "$OUT/api/branches_raw.json" > "$OUT/api/branches.json" - -rm -f "$OUT/api/branches_raw.json" - -api_array "repos/{owner}/{repo}/tags?per_page=100" \ - "$OUT/api/tags_raw.json" - -jq ' -map({ - name, - sha: .commit.sha, - tarball_url, - zipball_url -}) -' "$OUT/api/tags_raw.json" > "$OUT/api/tags.json" - -rm -f "$OUT/api/tags_raw.json" - -api_array "repos/{owner}/{repo}/releases?per_page=100" \ - "$OUT/api/releases_raw.json" - -jq ' -map({ - id, - tag_name, - name, - draft, - prerelease, - created_at, - published_at, - author: (.author.login // null), - assets_count: (.assets | length) -}) -' "$OUT/api/releases_raw.json" > "$OUT/api/releases.json" - -rm -f "$OUT/api/releases_raw.json" - -# --------------------------------------------------------------------------- -# Issues -# The issues endpoint also returns pull requests, which are filtered out. -# Bodies and comments are intentionally not exported. -# --------------------------------------------------------------------------- - -api_array "repos/{owner}/{repo}/issues?state=all&per_page=100" \ - "$OUT/api/issues_raw.json" - -jq ' -map( - select((has("pull_request") | not)) - | { - number, - title, - state, - state_reason, - created_at, - updated_at, - closed_at, - author: (.user.login // null), - comments, - locked, - labels: [.labels[].name], - assignees: [.assignees[].login], - milestone: (.milestone.title // null) - } -) -' "$OUT/api/issues_raw.json" > "$OUT/api/issues.json" - -rm -f "$OUT/api/issues_raw.json" - -# --------------------------------------------------------------------------- -# Pull requests -# Fetch each pull request to obtain additions, deletions, commits, and changed files. -# --------------------------------------------------------------------------- - -api_array "repos/{owner}/{repo}/pulls?state=all&per_page=100" \ - "$OUT/api/pulls_index.json" - -while IFS= read -r pr_number; do - [[ -z "$pr_number" ]] && continue - - output="$OUT/api/pr_details/${pr_number}.json" - - if gh api "repos/{owner}/{repo}/pulls/${pr_number}" 2>>"$ERROR_LOG" | - jq '{ - number, - title, - state, - draft, - created_at, - updated_at, - closed_at, - merged_at, - author: (.user.login // null), - merged_by: (.merged_by.login // null), - base_branch: .base.ref, - head_branch: .head.ref, - mergeable, - mergeable_state, - commits, - additions, - deletions, - changed_files, - comments, - review_comments, - maintainer_can_modify, - labels: [.labels[].name] - }' > "$output"; then - : - else - rm -f "$output" - fi -done < <(jq -r '.[].number' "$OUT/api/pulls_index.json") - -shopt -s nullglob -PR_FILES=("$OUT"/api/pr_details/*.json) - -if (( ${#PR_FILES[@]} > 0 )); then - jq -s 'sort_by(.number)' "${PR_FILES[@]}" > "$OUT/api/pulls.json" -else - echo '[]' > "$OUT/api/pulls.json" -fi - -rm -f "$OUT/api/pulls_index.json" - -# --------------------------------------------------------------------------- -# GitHub Actions -# --------------------------------------------------------------------------- - -if gh api --paginate \ - "repos/{owner}/{repo}/actions/workflows?per_page=100" \ - --jq '.workflows[]' 2>>"$ERROR_LOG" | - jq -s 'map({ - id, - name, - path, - state, - created_at, - updated_at - })' > "$OUT/api/workflows.json"; then - : -else - echo '[]' > "$OUT/api/workflows.json" -fi - -if gh api --paginate \ - "repos/{owner}/{repo}/actions/runs?per_page=100" \ - --jq '.workflow_runs[]' 2>>"$ERROR_LOG" | - jq -s 'map({ - id, - name, - display_title, - event, - status, - conclusion, - workflow_id, - run_number, - run_attempt, - head_branch, - head_sha, - created_at, - run_started_at, - updated_at, - actor: (.actor.login // null) - })' > "$OUT/api/workflow_runs.json"; then - : -else - echo '[]' > "$OUT/api/workflow_runs.json" -fi - -# --------------------------------------------------------------------------- -# Statistics calculated by GitHub -# --------------------------------------------------------------------------- - -stat_api "repos/{owner}/{repo}/stats/contributors" \ - "$OUT/api/stats/contributors.json" - -stat_api "repos/{owner}/{repo}/stats/commit_activity" \ - "$OUT/api/stats/commit_activity.json" - -stat_api "repos/{owner}/{repo}/stats/code_frequency" \ - "$OUT/api/stats/code_frequency.json" - -stat_api "repos/{owner}/{repo}/stats/participation" \ - "$OUT/api/stats/participation.json" - -stat_api "repos/{owner}/{repo}/stats/punch_card" \ - "$OUT/api/stats/punch_card.json" - -# --------------------------------------------------------------------------- -# Traffic data requires sufficient permissions and covers a limited recent window. -# --------------------------------------------------------------------------- - -api_object "repos/{owner}/{repo}/traffic/clones" \ - "$OUT/api/traffic/clones.json" - -api_object "repos/{owner}/{repo}/traffic/views" \ - "$OUT/api/traffic/views.json" - -api_object "repos/{owner}/{repo}/traffic/popular/paths" \ - "$OUT/api/traffic/popular_paths.json" - -api_object "repos/{owner}/{repo}/traffic/popular/referrers" \ - "$OUT/api/traffic/popular_referrers.json" - -# --------------------------------------------------------------------------- -# Local Git history -# --------------------------------------------------------------------------- - -git remote -v > "$OUT/git/remotes.txt" -git branch -a -vv > "$OUT/git/branches.txt" -git tag --sort=-creatordate > "$OUT/git/tags.txt" -git status --short --branch > "$OUT/git/status.txt" -git count-objects -vH > "$OUT/git/storage.txt" -git shortlog -s -n --all > "$OUT/git/shortlog.txt" -git ls-files > "$OUT/git/tracked_files.txt" - -git rev-list --count --all > "$OUT/git/total_commits_all_refs.txt" -git rev-list --count HEAD > "$OUT/git/total_commits_current_branch.txt" - -# Generate history CSV files without exporting author emails or file contents. -python3 - "$OUT" <<'PY' -import csv -import json -import os -import pathlib -import subprocess -import sys -from collections import defaultdict -from datetime import datetime - -out = pathlib.Path(sys.argv[1]) - -marker = "__GITHUB_ANALYSIS_COMMIT__" -separator = "\x1f" - -pretty = ( - marker - + separator + "%H" - + separator + "%aI" - + separator + "%an" - + separator + "%P" - + separator + "%s" -) - -result = subprocess.run( - [ - "git", - "log", - "--all", - "--numstat", - "--date=iso-strict", - f"--pretty=format:{pretty}", - ], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - errors="replace", - check=True, -) - -commits = [] -current = None - -def finish_current(): - global current - - if current is not None: - commits.append(current) - current = None - -for line in result.stdout.splitlines(): - if line.startswith(marker + separator): - finish_current() - - parts = line.split(separator) - - if len(parts) < 6: - continue - - parents = parts[4].split() if parts[4] else [] - - current = { - "sha": parts[1], - "date": parts[2], - "author": parts[3], - "parent_count": len(parents), - "subject": separator.join(parts[5:]), - "files_changed": 0, - "additions": 0, - "deletions": 0, - "binary_files": 0, - } - - continue - - if current is None or "\t" not in line: - continue - - fields = line.split("\t", 2) - - if len(fields) != 3: - continue - - added, deleted, _filename = fields - - current["files_changed"] += 1 - - if added.isdigit(): - current["additions"] += int(added) - else: - current["binary_files"] += 1 - - if deleted.isdigit(): - current["deletions"] += int(deleted) - -finish_current() - -commits.sort(key=lambda item: item["date"]) - -commit_fields = [ - "sha", - "date", - "author", - "parent_count", - "subject", - "files_changed", - "additions", - "deletions", - "binary_files", -] - -with (out / "csv" / "commits.csv").open( - "w", newline="", encoding="utf-8" -) as handle: - writer = csv.DictWriter(handle, fieldnames=commit_fields) - writer.writeheader() - writer.writerows(commits) - -authors = defaultdict( - lambda: { - "commits": 0, - "merge_commits": 0, - "files_changed": 0, - "additions": 0, - "deletions": 0, - } -) - -months = defaultdict( - lambda: { - "commits": 0, - "merge_commits": 0, - "files_changed": 0, - "additions": 0, - "deletions": 0, - } -) - -weekdays = defaultdict(int) -hours = defaultdict(int) - -for commit in commits: - author_stats = authors[commit["author"]] - author_stats["commits"] += 1 - author_stats["merge_commits"] += int(commit["parent_count"] > 1) - author_stats["files_changed"] += commit["files_changed"] - author_stats["additions"] += commit["additions"] - author_stats["deletions"] += commit["deletions"] - - month = commit["date"][:7] if commit["date"] else "unknown" - month_stats = months[month] - month_stats["commits"] += 1 - month_stats["merge_commits"] += int(commit["parent_count"] > 1) - month_stats["files_changed"] += commit["files_changed"] - month_stats["additions"] += commit["additions"] - month_stats["deletions"] += commit["deletions"] - - try: - parsed = datetime.fromisoformat(commit["date"]) - weekdays[parsed.strftime("%A")] += 1 - hours[parsed.hour] += 1 - except (TypeError, ValueError): - pass - -with (out / "csv" / "authors.csv").open( - "w", newline="", encoding="utf-8" -) as handle: - fieldnames = [ - "author", - "commits", - "merge_commits", - "files_changed", - "additions", - "deletions", - "net_lines", - ] - - writer = csv.DictWriter(handle, fieldnames=fieldnames) - writer.writeheader() - - for author, stats in sorted( - authors.items(), - key=lambda item: item[1]["commits"], - reverse=True, - ): - writer.writerow( - { - "author": author, - **stats, - "net_lines": stats["additions"] - stats["deletions"], - } - ) - -with (out / "csv" / "monthly_activity.csv").open( - "w", newline="", encoding="utf-8" -) as handle: - fieldnames = [ - "month", - "commits", - "merge_commits", - "files_changed", - "additions", - "deletions", - "net_lines", - ] - - writer = csv.DictWriter(handle, fieldnames=fieldnames) - writer.writeheader() - - for month in sorted(months): - stats = months[month] - writer.writerow( - { - "month": month, - **stats, - "net_lines": stats["additions"] - stats["deletions"], - } - ) - -with (out / "csv" / "commit_hours.csv").open( - "w", newline="", encoding="utf-8" -) as handle: - writer = csv.writer(handle) - writer.writerow(["hour", "commits"]) - - for hour in range(24): - writer.writerow([hour, hours[hour]]) - -with (out / "csv" / "commit_weekdays.csv").open( - "w", newline="", encoding="utf-8" -) as handle: - writer = csv.writer(handle) - writer.writerow(["weekday", "commits"]) - - ordered_days = [ - "Monday", - "Tuesday", - "Wednesday", - "Thursday", - "Friday", - "Saturday", - "Sunday", - ] - - for day in ordered_days: - writer.writerow([day, weekdays[day]]) - -# Tracked-file statistics without copying file contents. -tracked = subprocess.run( - ["git", "ls-files", "-z"], - stdout=subprocess.PIPE, - check=True, -).stdout.split(b"\0") - -extensions = defaultdict( - lambda: { - "files": 0, - "bytes": 0, - } -) - -total_files = 0 -total_bytes = 0 - -for raw_path in tracked: - if not raw_path: - continue - - path_text = raw_path.decode("utf-8", errors="replace") - path = pathlib.Path(path_text) - - suffix = path.suffix.lower() or "[no extension]" - - try: - size = path.stat().st_size - except OSError: - size = 0 - - extensions[suffix]["files"] += 1 - extensions[suffix]["bytes"] += size - - total_files += 1 - total_bytes += size - -with (out / "csv" / "file_types.csv").open( - "w", newline="", encoding="utf-8" -) as handle: - writer = csv.writer(handle) - writer.writerow(["extension", "files", "bytes"]) - - for extension, values in sorted( - extensions.items(), - key=lambda item: item[1]["bytes"], - reverse=True, - ): - writer.writerow( - [ - extension, - values["files"], - values["bytes"], - ] - ) - -summary = { - "generated_at": datetime.now().astimezone().isoformat(), - "commits_all_refs": len(commits), - "authors": len(authors), - "first_commit": commits[0]["date"] if commits else None, - "last_commit": commits[-1]["date"] if commits else None, - "total_additions": sum(commit["additions"] for commit in commits), - "total_deletions": sum(commit["deletions"] for commit in commits), - "merge_commits": sum(commit["parent_count"] > 1 for commit in commits), - "tracked_files": total_files, - "tracked_file_bytes": total_bytes, -} - -with (out / "git" / "local_summary.json").open( - "w", encoding="utf-8" -) as handle: - json.dump(summary, handle, ensure_ascii=False, indent=2) - handle.write("\n") -PY - -# --------------------------------------------------------------------------- -# CLOC language statistics -# --------------------------------------------------------------------------- - -if command -v cloc >/dev/null 2>&1; then - cloc \ - --list-file="$OUT/git/tracked_files.txt" \ - --json \ - --quiet \ - --out="$OUT/git/cloc.json" \ - 2>>"$ERROR_LOG" || true -else - cat > "$OUT/git/cloc.json" < "$OUT/csv/contributors.csv" - -jq -r ' -(["number","state","created_at","closed_at","author","comments","title","labels"] | @csv), -(.[] | [ - .number, - .state, - .created_at, - .closed_at, - .author, - .comments, - .title, - (.labels | join("; ")) -] | @csv) -' "$OUT/api/issues.json" > "$OUT/csv/issues.csv" - -jq -r ' -(["number","state","draft","created_at","merged_at","author","base_branch","head_branch","commits","additions","deletions","changed_files","title"] | @csv), -(.[] | [ - .number, - .state, - .draft, - .created_at, - .merged_at, - .author, - .base_branch, - .head_branch, - .commits, - .additions, - .deletions, - .changed_files, - .title -] | @csv) -' "$OUT/api/pulls.json" > "$OUT/csv/pull_requests.csv" - -jq -r ' -(["id","name","event","status","conclusion","run_number","head_branch","created_at","run_started_at","updated_at","actor"] | @csv), -(.[] | [ - .id, - .name, - .event, - .status, - .conclusion, - .run_number, - .head_branch, - .created_at, - .run_started_at, - .updated_at, - .actor -] | @csv) -' "$OUT/api/workflow_runs.json" > "$OUT/csv/workflow_runs.csv" - -# --------------------------------------------------------------------------- -# Human-readable summary -# --------------------------------------------------------------------------- - -REPO_NAME="$( - jq -r '.full_name // "unknown"' "$OUT/api/repository.json" -)" - -{ - echo "GitHub repository analysis export" - echo "Repository: $REPO_NAME" - echo "Generated: $(date --iso-8601=seconds)" - echo - echo "Main files:" - echo "- api/repository.json" - echo "- api/issues.json" - echo "- api/pulls.json" - echo "- api/contributors.json" - echo "- api/workflow_runs.json" - echo "- api/stats/" - echo "- api/traffic/" - echo "- git/local_summary.json" - echo "- git/cloc.json" - echo "- csv/commits.csv" - echo "- csv/monthly_activity.csv" - echo "- csv/authors.csv" - echo "- csv/file_types.csv" -} > "$OUT/README.txt" - -ZIP="${OUT}.zip" - -zip -qr "$ZIP" "$OUT" - -echo -echo "Export completed." -echo "Directory: $OUT" -echo "Archive: $ZIP" +#!/usr/bin/env bash + +set -Eeuo pipefail + +command -v gh >/dev/null || { + echo "Error: GitHub CLI (gh) is required." + exit 1 +} + +command -v jq >/dev/null || { + echo "Error: jq is required." + exit 1 +} + +command -v python3 >/dev/null || { + echo "Error: python3 is required." + exit 1 +} + +git rev-parse --is-inside-work-tree >/dev/null 2>&1 || { + echo "Error: run this script inside the Git repository." + exit 1 +} + +gh auth status >/dev/null 2>&1 || { + echo "Error: authenticate first with: gh auth login" + exit 1 +} + +TIMESTAMP="$(date +%Y%m%d-%H%M%S)" +OUT="github-analysis-${TIMESTAMP}" + +mkdir -p \ + "$OUT/api/stats" \ + "$OUT/api/traffic" \ + "$OUT/api/pr_details" \ + "$OUT/git" \ + "$OUT/csv" + +ERROR_LOG="$OUT/api_errors.log" +touch "$ERROR_LOG" + +echo "Exporting repository analysis to: $OUT" + +api_object() { + local endpoint="$1" + local output="$2" + + if ! gh api "$endpoint" > "$output" 2>>"$ERROR_LOG"; then + echo '{"available":false,"reason":"API request failed or permission unavailable"}' > "$output" + fi +} + +api_array() { + local endpoint="$1" + local output="$2" + + if ! gh api --paginate "$endpoint" --jq '.[]' 2>>"$ERROR_LOG" | + jq -s '.' > "$output"; then + echo '[]' > "$output" + fi +} + +stat_api() { + local endpoint="$1" + local output="$2" + local temporary="${output}.tmp" + + for attempt in 1 2 3 4 5; do + rm -f "$temporary" + + gh api "$endpoint" > "$temporary" 2>>"$ERROR_LOG" || true + + if [[ -s "$temporary" ]]; then + mv "$temporary" "$output" + return 0 + fi + + sleep 3 + done + + rm -f "$temporary" + + cat > "$output" < "$OUT/api/contributors.json" + +rm -f "$OUT/api/contributors_raw.json" + +# --------------------------------------------------------------------------- +# Branches, tags, and releases +# --------------------------------------------------------------------------- + +api_array "repos/{owner}/{repo}/branches?per_page=100" \ + "$OUT/api/branches_raw.json" + +jq ' +map({ + name, + sha: .commit.sha, + protected +}) +' "$OUT/api/branches_raw.json" > "$OUT/api/branches.json" + +rm -f "$OUT/api/branches_raw.json" + +api_array "repos/{owner}/{repo}/tags?per_page=100" \ + "$OUT/api/tags_raw.json" + +jq ' +map({ + name, + sha: .commit.sha, + tarball_url, + zipball_url +}) +' "$OUT/api/tags_raw.json" > "$OUT/api/tags.json" + +rm -f "$OUT/api/tags_raw.json" + +api_array "repos/{owner}/{repo}/releases?per_page=100" \ + "$OUT/api/releases_raw.json" + +jq ' +map({ + id, + tag_name, + name, + draft, + prerelease, + created_at, + published_at, + author: (.author.login // null), + assets_count: (.assets | length) +}) +' "$OUT/api/releases_raw.json" > "$OUT/api/releases.json" + +rm -f "$OUT/api/releases_raw.json" + +# --------------------------------------------------------------------------- +# Issues +# The issues endpoint also returns pull requests, which are filtered out. +# Bodies and comments are intentionally not exported. +# --------------------------------------------------------------------------- + +api_array "repos/{owner}/{repo}/issues?state=all&per_page=100" \ + "$OUT/api/issues_raw.json" + +jq ' +map( + select((has("pull_request") | not)) + | { + number, + title, + state, + state_reason, + created_at, + updated_at, + closed_at, + author: (.user.login // null), + comments, + locked, + labels: [.labels[].name], + assignees: [.assignees[].login], + milestone: (.milestone.title // null) + } +) +' "$OUT/api/issues_raw.json" > "$OUT/api/issues.json" + +rm -f "$OUT/api/issues_raw.json" + +# --------------------------------------------------------------------------- +# Pull requests +# Fetch each pull request to obtain additions, deletions, commits, and changed files. +# --------------------------------------------------------------------------- + +api_array "repos/{owner}/{repo}/pulls?state=all&per_page=100" \ + "$OUT/api/pulls_index.json" + +while IFS= read -r pr_number; do + [[ -z "$pr_number" ]] && continue + + output="$OUT/api/pr_details/${pr_number}.json" + + if gh api "repos/{owner}/{repo}/pulls/${pr_number}" 2>>"$ERROR_LOG" | + jq '{ + number, + title, + state, + draft, + created_at, + updated_at, + closed_at, + merged_at, + author: (.user.login // null), + merged_by: (.merged_by.login // null), + base_branch: .base.ref, + head_branch: .head.ref, + mergeable, + mergeable_state, + commits, + additions, + deletions, + changed_files, + comments, + review_comments, + maintainer_can_modify, + labels: [.labels[].name] + }' > "$output"; then + : + else + rm -f "$output" + fi +done < <(jq -r '.[].number' "$OUT/api/pulls_index.json") + +shopt -s nullglob +PR_FILES=("$OUT"/api/pr_details/*.json) + +if (( ${#PR_FILES[@]} > 0 )); then + jq -s 'sort_by(.number)' "${PR_FILES[@]}" > "$OUT/api/pulls.json" +else + echo '[]' > "$OUT/api/pulls.json" +fi + +rm -f "$OUT/api/pulls_index.json" + +# --------------------------------------------------------------------------- +# GitHub Actions +# --------------------------------------------------------------------------- + +if gh api --paginate \ + "repos/{owner}/{repo}/actions/workflows?per_page=100" \ + --jq '.workflows[]' 2>>"$ERROR_LOG" | + jq -s 'map({ + id, + name, + path, + state, + created_at, + updated_at + })' > "$OUT/api/workflows.json"; then + : +else + echo '[]' > "$OUT/api/workflows.json" +fi + +if gh api --paginate \ + "repos/{owner}/{repo}/actions/runs?per_page=100" \ + --jq '.workflow_runs[]' 2>>"$ERROR_LOG" | + jq -s 'map({ + id, + name, + display_title, + event, + status, + conclusion, + workflow_id, + run_number, + run_attempt, + head_branch, + head_sha, + created_at, + run_started_at, + updated_at, + actor: (.actor.login // null) + })' > "$OUT/api/workflow_runs.json"; then + : +else + echo '[]' > "$OUT/api/workflow_runs.json" +fi + +# --------------------------------------------------------------------------- +# Statistics calculated by GitHub +# --------------------------------------------------------------------------- + +stat_api "repos/{owner}/{repo}/stats/contributors" \ + "$OUT/api/stats/contributors.json" + +stat_api "repos/{owner}/{repo}/stats/commit_activity" \ + "$OUT/api/stats/commit_activity.json" + +stat_api "repos/{owner}/{repo}/stats/code_frequency" \ + "$OUT/api/stats/code_frequency.json" + +stat_api "repos/{owner}/{repo}/stats/participation" \ + "$OUT/api/stats/participation.json" + +stat_api "repos/{owner}/{repo}/stats/punch_card" \ + "$OUT/api/stats/punch_card.json" + +# --------------------------------------------------------------------------- +# Traffic data requires sufficient permissions and covers a limited recent window. +# --------------------------------------------------------------------------- + +api_object "repos/{owner}/{repo}/traffic/clones" \ + "$OUT/api/traffic/clones.json" + +api_object "repos/{owner}/{repo}/traffic/views" \ + "$OUT/api/traffic/views.json" + +api_object "repos/{owner}/{repo}/traffic/popular/paths" \ + "$OUT/api/traffic/popular_paths.json" + +api_object "repos/{owner}/{repo}/traffic/popular/referrers" \ + "$OUT/api/traffic/popular_referrers.json" + +# --------------------------------------------------------------------------- +# Local Git history +# --------------------------------------------------------------------------- + +git remote -v > "$OUT/git/remotes.txt" +git branch -a -vv > "$OUT/git/branches.txt" +git tag --sort=-creatordate > "$OUT/git/tags.txt" +git status --short --branch > "$OUT/git/status.txt" +git count-objects -vH > "$OUT/git/storage.txt" +git shortlog -s -n --all > "$OUT/git/shortlog.txt" +git ls-files > "$OUT/git/tracked_files.txt" + +git rev-list --count --all > "$OUT/git/total_commits_all_refs.txt" +git rev-list --count HEAD > "$OUT/git/total_commits_current_branch.txt" + +# Generate history CSV files without exporting author emails or file contents. +python3 - "$OUT" <<'PY' +import csv +import json +import os +import pathlib +import subprocess +import sys +from collections import defaultdict +from datetime import datetime + +out = pathlib.Path(sys.argv[1]) + +marker = "__GITHUB_ANALYSIS_COMMIT__" +separator = "\x1f" + +pretty = ( + marker + + separator + "%H" + + separator + "%aI" + + separator + "%an" + + separator + "%P" + + separator + "%s" +) + +result = subprocess.run( + [ + "git", + "log", + "--all", + "--numstat", + "--date=iso-strict", + f"--pretty=format:{pretty}", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + errors="replace", + check=True, +) + +commits = [] +current = None + +def finish_current(): + global current + + if current is not None: + commits.append(current) + current = None + +for line in result.stdout.splitlines(): + if line.startswith(marker + separator): + finish_current() + + parts = line.split(separator) + + if len(parts) < 6: + continue + + parents = parts[4].split() if parts[4] else [] + + current = { + "sha": parts[1], + "date": parts[2], + "author": parts[3], + "parent_count": len(parents), + "subject": separator.join(parts[5:]), + "files_changed": 0, + "additions": 0, + "deletions": 0, + "binary_files": 0, + } + + continue + + if current is None or "\t" not in line: + continue + + fields = line.split("\t", 2) + + if len(fields) != 3: + continue + + added, deleted, _filename = fields + + current["files_changed"] += 1 + + if added.isdigit(): + current["additions"] += int(added) + else: + current["binary_files"] += 1 + + if deleted.isdigit(): + current["deletions"] += int(deleted) + +finish_current() + +commits.sort(key=lambda item: item["date"]) + +commit_fields = [ + "sha", + "date", + "author", + "parent_count", + "subject", + "files_changed", + "additions", + "deletions", + "binary_files", +] + +with (out / "csv" / "commits.csv").open( + "w", newline="", encoding="utf-8" +) as handle: + writer = csv.DictWriter(handle, fieldnames=commit_fields) + writer.writeheader() + writer.writerows(commits) + +authors = defaultdict( + lambda: { + "commits": 0, + "merge_commits": 0, + "files_changed": 0, + "additions": 0, + "deletions": 0, + } +) + +months = defaultdict( + lambda: { + "commits": 0, + "merge_commits": 0, + "files_changed": 0, + "additions": 0, + "deletions": 0, + } +) + +weekdays = defaultdict(int) +hours = defaultdict(int) + +for commit in commits: + author_stats = authors[commit["author"]] + author_stats["commits"] += 1 + author_stats["merge_commits"] += int(commit["parent_count"] > 1) + author_stats["files_changed"] += commit["files_changed"] + author_stats["additions"] += commit["additions"] + author_stats["deletions"] += commit["deletions"] + + month = commit["date"][:7] if commit["date"] else "unknown" + month_stats = months[month] + month_stats["commits"] += 1 + month_stats["merge_commits"] += int(commit["parent_count"] > 1) + month_stats["files_changed"] += commit["files_changed"] + month_stats["additions"] += commit["additions"] + month_stats["deletions"] += commit["deletions"] + + try: + parsed = datetime.fromisoformat(commit["date"]) + weekdays[parsed.strftime("%A")] += 1 + hours[parsed.hour] += 1 + except (TypeError, ValueError): + pass + +with (out / "csv" / "authors.csv").open( + "w", newline="", encoding="utf-8" +) as handle: + fieldnames = [ + "author", + "commits", + "merge_commits", + "files_changed", + "additions", + "deletions", + "net_lines", + ] + + writer = csv.DictWriter(handle, fieldnames=fieldnames) + writer.writeheader() + + for author, stats in sorted( + authors.items(), + key=lambda item: item[1]["commits"], + reverse=True, + ): + writer.writerow( + { + "author": author, + **stats, + "net_lines": stats["additions"] - stats["deletions"], + } + ) + +with (out / "csv" / "monthly_activity.csv").open( + "w", newline="", encoding="utf-8" +) as handle: + fieldnames = [ + "month", + "commits", + "merge_commits", + "files_changed", + "additions", + "deletions", + "net_lines", + ] + + writer = csv.DictWriter(handle, fieldnames=fieldnames) + writer.writeheader() + + for month in sorted(months): + stats = months[month] + writer.writerow( + { + "month": month, + **stats, + "net_lines": stats["additions"] - stats["deletions"], + } + ) + +with (out / "csv" / "commit_hours.csv").open( + "w", newline="", encoding="utf-8" +) as handle: + writer = csv.writer(handle) + writer.writerow(["hour", "commits"]) + + for hour in range(24): + writer.writerow([hour, hours[hour]]) + +with (out / "csv" / "commit_weekdays.csv").open( + "w", newline="", encoding="utf-8" +) as handle: + writer = csv.writer(handle) + writer.writerow(["weekday", "commits"]) + + ordered_days = [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday", + ] + + for day in ordered_days: + writer.writerow([day, weekdays[day]]) + +# Tracked-file statistics without copying file contents. +tracked = subprocess.run( + ["git", "ls-files", "-z"], + stdout=subprocess.PIPE, + check=True, +).stdout.split(b"\0") + +extensions = defaultdict( + lambda: { + "files": 0, + "bytes": 0, + } +) + +total_files = 0 +total_bytes = 0 + +for raw_path in tracked: + if not raw_path: + continue + + path_text = raw_path.decode("utf-8", errors="replace") + path = pathlib.Path(path_text) + + suffix = path.suffix.lower() or "[no extension]" + + try: + size = path.stat().st_size + except OSError: + size = 0 + + extensions[suffix]["files"] += 1 + extensions[suffix]["bytes"] += size + + total_files += 1 + total_bytes += size + +with (out / "csv" / "file_types.csv").open( + "w", newline="", encoding="utf-8" +) as handle: + writer = csv.writer(handle) + writer.writerow(["extension", "files", "bytes"]) + + for extension, values in sorted( + extensions.items(), + key=lambda item: item[1]["bytes"], + reverse=True, + ): + writer.writerow( + [ + extension, + values["files"], + values["bytes"], + ] + ) + +summary = { + "generated_at": datetime.now().astimezone().isoformat(), + "commits_all_refs": len(commits), + "authors": len(authors), + "first_commit": commits[0]["date"] if commits else None, + "last_commit": commits[-1]["date"] if commits else None, + "total_additions": sum(commit["additions"] for commit in commits), + "total_deletions": sum(commit["deletions"] for commit in commits), + "merge_commits": sum(commit["parent_count"] > 1 for commit in commits), + "tracked_files": total_files, + "tracked_file_bytes": total_bytes, +} + +with (out / "git" / "local_summary.json").open( + "w", encoding="utf-8" +) as handle: + json.dump(summary, handle, ensure_ascii=False, indent=2) + handle.write("\n") +PY + +# --------------------------------------------------------------------------- +# CLOC language statistics +# --------------------------------------------------------------------------- + +if command -v cloc >/dev/null 2>&1; then + cloc \ + --list-file="$OUT/git/tracked_files.txt" \ + --json \ + --quiet \ + --out="$OUT/git/cloc.json" \ + 2>>"$ERROR_LOG" || true +else + cat > "$OUT/git/cloc.json" < "$OUT/csv/contributors.csv" + +jq -r ' +(["number","state","created_at","closed_at","author","comments","title","labels"] | @csv), +(.[] | [ + .number, + .state, + .created_at, + .closed_at, + .author, + .comments, + .title, + (.labels | join("; ")) +] | @csv) +' "$OUT/api/issues.json" > "$OUT/csv/issues.csv" + +jq -r ' +(["number","state","draft","created_at","merged_at","author","base_branch","head_branch","commits","additions","deletions","changed_files","title"] | @csv), +(.[] | [ + .number, + .state, + .draft, + .created_at, + .merged_at, + .author, + .base_branch, + .head_branch, + .commits, + .additions, + .deletions, + .changed_files, + .title +] | @csv) +' "$OUT/api/pulls.json" > "$OUT/csv/pull_requests.csv" + +jq -r ' +(["id","name","event","status","conclusion","run_number","head_branch","created_at","run_started_at","updated_at","actor"] | @csv), +(.[] | [ + .id, + .name, + .event, + .status, + .conclusion, + .run_number, + .head_branch, + .created_at, + .run_started_at, + .updated_at, + .actor +] | @csv) +' "$OUT/api/workflow_runs.json" > "$OUT/csv/workflow_runs.csv" + +# --------------------------------------------------------------------------- +# Human-readable summary +# --------------------------------------------------------------------------- + +REPO_NAME="$( + jq -r '.full_name // "unknown"' "$OUT/api/repository.json" +)" + +{ + echo "GitHub repository analysis export" + echo "Repository: $REPO_NAME" + echo "Generated: $(date --iso-8601=seconds)" + echo + echo "Main files:" + echo "- api/repository.json" + echo "- api/issues.json" + echo "- api/pulls.json" + echo "- api/contributors.json" + echo "- api/workflow_runs.json" + echo "- api/stats/" + echo "- api/traffic/" + echo "- git/local_summary.json" + echo "- git/cloc.json" + echo "- csv/commits.csv" + echo "- csv/monthly_activity.csv" + echo "- csv/authors.csv" + echo "- csv/file_types.csv" +} > "$OUT/README.txt" + +ZIP="${OUT}.zip" + +zip -qr "$ZIP" "$OUT" + +echo +echo "Export completed." +echo "Directory: $OUT" +echo "Archive: $ZIP" diff --git a/scripts/package_release.py b/scripts/package_release.py index cdce5f3..fccb04c 100644 --- a/scripts/package_release.py +++ b/scripts/package_release.py @@ -1,404 +1,404 @@ -#!/usr/bin/env python3 -"""Build clean, deterministic MagicAI source and full release archives.""" - -from __future__ import annotations - -import argparse -from dataclasses import dataclass -from datetime import datetime, timezone -import hashlib -import importlib.util -import json -import os -from pathlib import Path, PurePosixPath -import stat -import subprocess -import sys -import tomllib -import zipfile - - -FULL_SOURCE_FILES = ( - PurePosixPath("sources/scryfall/oracle-cards.json"), - PurePosixPath("sources/scryfall/rulings.json"), -) - -EXCLUDED_PREFIXES = ( - ".git/", - ".venv/", - "backups/", - "backup/", - "build/", - "dist/", - "github-analysis-", - "htmlcov/", - "logs/", - "node_modules/", - "quality-results/", - "reports/", - "resultado_", - "site/", - "test-results/", -) - -EXCLUDED_PARTS = { - "__pycache__", - ".mypy_cache", - ".pytest_cache", - ".ruff_cache", -} - - -@dataclass(frozen=True, slots=True) -class ReleaseIdentity: - public_version: str - package_version: str - tag: str - channel: str - codename: str - - -@dataclass(frozen=True, slots=True) -class PackageEntry: - relative_path: PurePosixPath - source_path: Path - executable: bool - - -def run_git(repo: Path, *arguments: str, text: bool = True) -> str | bytes: - result = subprocess.run( - ["git", "-C", str(repo), *arguments], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - check=False, - text=text, - ) - if result.returncode != 0: - detail = result.stderr.strip() if text else result.stderr.decode(errors="replace").strip() - raise RuntimeError(f"git {' '.join(arguments)} failed: {detail}") - return result.stdout - - -def repository_root(configured: str | None) -> Path: - candidate = Path(configured or Path.cwd()).expanduser().resolve() - output = subprocess.run( - ["git", "-C", str(candidate), "rev-parse", "--show-toplevel"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - check=False, - ) - if output.returncode != 0: - raise RuntimeError(f"{candidate} is not inside a Git repository") - return Path(output.stdout.strip()).resolve() - - -def load_release_identity(repo: Path) -> ReleaseIdentity: - module_path = repo / "magicai" / "versioning.py" - spec = importlib.util.spec_from_file_location("magicai_release_versioning", module_path) - if spec is None or spec.loader is None: - raise RuntimeError(f"Unable to load {module_path}") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - - pyproject = tomllib.loads((repo / "pyproject.toml").read_text(encoding="utf-8")) - package_version = str(pyproject["project"]["version"]) - expected_package_version = str(module.PACKAGE_FALLBACK_VERSION) - if package_version != expected_package_version: - raise RuntimeError( - "Version mismatch: pyproject.toml declares " - f"{package_version!r}, but magicai/versioning.py declares " - f"{expected_package_version!r}." - ) - - return ReleaseIdentity( - public_version=str(module.PUBLIC_VERSION), - package_version=package_version, - tag=str(module.RELEASE_TAG), - channel=str(module.RELEASE_CHANNEL), - codename=str(module.RELEASE_CODENAME), - ) - - -def tracked_paths(repo: Path) -> list[PurePosixPath]: - raw = run_git(repo, "ls-files", "-z", text=False) - assert isinstance(raw, bytes) - paths = [] - for value in raw.split(b"\0"): - if not value: - continue - paths.append(PurePosixPath(value.decode("utf-8", errors="strict"))) - return sorted(paths, key=str) - - -def should_exclude(path: PurePosixPath) -> bool: - value = path.as_posix() - if any(part in EXCLUDED_PARTS for part in path.parts): - return True - return any(value == prefix.rstrip("/") or value.startswith(prefix) for prefix in EXCLUDED_PREFIXES) - - -def collect_entries(repo: Path, mode: str) -> list[PackageEntry]: - entries: dict[PurePosixPath, PackageEntry] = {} - - for relative in tracked_paths(repo): - if should_exclude(relative): - continue - if mode == "source" and relative in FULL_SOURCE_FILES: - continue - source = repo / relative - if not source.is_file(): - raise RuntimeError(f"Tracked file is missing from the working tree: {relative}") - if source.is_symlink(): - raise RuntimeError(f"Release packages do not include symbolic links: {relative}") - executable = bool(source.stat().st_mode & stat.S_IXUSR) - entries[relative] = PackageEntry(relative, source, executable) - - if mode == "full": - missing = [path for path in FULL_SOURCE_FILES if not (repo / path).is_file()] - if missing: - formatted = ", ".join(str(path) for path in missing) - raise RuntimeError( - "Full package requires local bulk sources. Missing: " + formatted - ) - for relative in FULL_SOURCE_FILES: - source = repo / relative - entries[relative] = PackageEntry(relative, source, False) - - return [entries[path] for path in sorted(entries, key=str)] - - -def sha256_bytes(content: bytes) -> str: - return hashlib.sha256(content).hexdigest() - - -def sha256_file(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as handle: - for chunk in iter(lambda: handle.read(1024 * 1024), b""): - digest.update(chunk) - return digest.hexdigest() - - -def source_epoch(repo: Path) -> int: - configured = os.environ.get("SOURCE_DATE_EPOCH") - if configured: - try: - return max(int(configured), 315532800) - except ValueError as error: - raise RuntimeError("SOURCE_DATE_EPOCH must be an integer") from error - - output = run_git(repo, "log", "-1", "--format=%ct") - assert isinstance(output, str) - return max(int(output.strip()), 315532800) - - -def zip_datetime(epoch: int) -> tuple[int, int, int, int, int, int]: - value = datetime.fromtimestamp(epoch, tz=timezone.utc) - second = value.second - (value.second % 2) - return value.year, value.month, value.day, value.hour, value.minute, second - - -def git_metadata(repo: Path) -> dict[str, str]: - commit = str(run_git(repo, "rev-parse", "HEAD")).strip() - branch = str(run_git(repo, "branch", "--show-current")).strip() or "detached" - status = str(run_git(repo, "status", "--porcelain", "--untracked-files=no")) - return { - "commit": commit, - "branch": branch, - "tracked_worktree": "clean" if not status.strip() else "modified", - } - - -def build_metadata( - *, - repo: Path, - identity: ReleaseIdentity, - mode: str, - entries: list[PackageEntry], - epoch: int, -) -> tuple[bytes, bytes]: - git_info = git_metadata(repo) - files = [] - for entry in entries: - files.append( - { - "path": entry.relative_path.as_posix(), - "size": entry.source_path.stat().st_size, - "sha256": sha256_file(entry.source_path), - } - ) - - generated_at = datetime.fromtimestamp(epoch, tz=timezone.utc).isoformat() - manifest = { - "schema_version": "1.0", - "project": "MagicAI", - "package_mode": mode, - "public_version": identity.public_version, - "package_version": identity.package_version, - "release_tag": identity.tag, - "release_channel": identity.channel, - "release_codename": identity.codename, - "generated_at": generated_at, - "git": git_info, - "files": files, - } - manifest_bytes = ( - json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n" - ).encode("utf-8") - - info = "\n".join( - [ - "MagicAI release package", - "", - f"Public version: {identity.public_version}", - f"Package version: {identity.package_version}", - f"Codename: {identity.codename}", - f"Channel: {identity.channel}", - f"Tag: {identity.tag}", - f"Package mode: {mode}", - f"Git branch: {git_info['branch']}", - f"Git commit: {git_info['commit']}", - f"Tracked worktree: {git_info['tracked_worktree']}", - f"Generated from commit time: {generated_at}", - f"Packaged files: {len(entries)}", - "", - "See PACKAGE_MANIFEST.json for per-file SHA-256 values.", - "", - ] - ).encode("utf-8") - return info, manifest_bytes - - -def write_zip_entry( - archive: zipfile.ZipFile, - archive_name: str, - content: bytes, - *, - timestamp: tuple[int, int, int, int, int, int], - executable: bool = False, -) -> None: - info = zipfile.ZipInfo(archive_name, date_time=timestamp) - info.compress_type = zipfile.ZIP_DEFLATED - info.create_system = 3 - mode = 0o755 if executable else 0o644 - info.external_attr = (stat.S_IFREG | mode) << 16 - archive.writestr(info, content, compresslevel=9) - - -def build_archive( - *, - repo: Path, - mode: str, - output_dir: Path, - force: bool, - allow_dirty: bool = False, -) -> Path: - identity = load_release_identity(repo) - current_git = git_metadata(repo) - if current_git["tracked_worktree"] != "clean" and not allow_dirty: - raise RuntimeError( - "Tracked files are modified. Commit or restore them before packaging, " - "or use --allow-dirty for a diagnostic archive." - ) - entries = collect_entries(repo, mode) - epoch = source_epoch(repo) - timestamp = zip_datetime(epoch) - root_name = f"MagicAI-v{identity.public_version}-{mode}" - output_dir.mkdir(parents=True, exist_ok=True) - archive_path = output_dir / f"{root_name}.zip" - checksum_path = archive_path.with_suffix(archive_path.suffix + ".sha256") - - if (archive_path.exists() or checksum_path.exists()) and not force: - raise RuntimeError( - f"Output already exists: {archive_path}. Use --force to replace it." - ) - - info_bytes, manifest_bytes = build_metadata( - repo=repo, - identity=identity, - mode=mode, - entries=entries, - epoch=epoch, - ) - - temporary = archive_path.with_suffix(".zip.tmp") - temporary.unlink(missing_ok=True) - try: - with zipfile.ZipFile(temporary, "w", allowZip64=True) as archive: - for entry in entries: - write_zip_entry( - archive, - f"{root_name}/{entry.relative_path.as_posix()}", - entry.source_path.read_bytes(), - timestamp=timestamp, - executable=entry.executable, - ) - write_zip_entry( - archive, - f"{root_name}/INFO.txt", - info_bytes, - timestamp=timestamp, - ) - write_zip_entry( - archive, - f"{root_name}/PACKAGE_MANIFEST.json", - manifest_bytes, - timestamp=timestamp, - ) - temporary.replace(archive_path) - finally: - temporary.unlink(missing_ok=True) - - checksum = sha256_file(archive_path) - checksum_path.write_text(f"{checksum} {archive_path.name}\n", encoding="utf-8") - return archive_path - - -def parse_arguments(argv: list[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - mode = parser.add_mutually_exclusive_group(required=True) - mode.add_argument("--source", action="store_true", help="Build the clean source package") - mode.add_argument("--full", action="store_true", help="Build the full package with local bulk sources") - parser.add_argument("--repo", help="Repository path; defaults to the current repository") - parser.add_argument( - "--output-dir", - default="dist/releases", - help="Output directory relative to the repository unless absolute", - ) - parser.add_argument("--force", action="store_true", help="Replace an existing archive") - parser.add_argument( - "--allow-dirty", - action="store_true", - help="Allow packaging modified tracked files for diagnostics", - ) - return parser.parse_args(argv) - - -def main(argv: list[str] | None = None) -> int: - arguments = parse_arguments(argv or sys.argv[1:]) - try: - repo = repository_root(arguments.repo) - output_dir = Path(arguments.output_dir).expanduser() - if not output_dir.is_absolute(): - output_dir = repo / output_dir - mode = "full" if arguments.full else "source" - archive = build_archive( - repo=repo, - mode=mode, - output_dir=output_dir.resolve(), - force=arguments.force, - allow_dirty=arguments.allow_dirty, - ) - except (OSError, RuntimeError, ValueError, KeyError) as error: - print(f"ERROR: {error}", file=sys.stderr) - return 1 - - checksum_path = archive.with_suffix(archive.suffix + ".sha256") - print(f"Created: {archive}") - print(f"Checksum: {checksum_path}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) +#!/usr/bin/env python3 +"""Build clean, deterministic MagicAI source and full release archives.""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +from datetime import datetime, timezone +import hashlib +import importlib.util +import json +import os +from pathlib import Path, PurePosixPath +import stat +import subprocess +import sys +import tomllib +import zipfile + + +FULL_SOURCE_FILES = ( + PurePosixPath("sources/scryfall/oracle-cards.json"), + PurePosixPath("sources/scryfall/rulings.json"), +) + +EXCLUDED_PREFIXES = ( + ".git/", + ".venv/", + "backups/", + "backup/", + "build/", + "dist/", + "github-analysis-", + "htmlcov/", + "logs/", + "node_modules/", + "quality-results/", + "reports/", + "resultado_", + "site/", + "test-results/", +) + +EXCLUDED_PARTS = { + "__pycache__", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", +} + + +@dataclass(frozen=True, slots=True) +class ReleaseIdentity: + public_version: str + package_version: str + tag: str + channel: str + codename: str + + +@dataclass(frozen=True, slots=True) +class PackageEntry: + relative_path: PurePosixPath + source_path: Path + executable: bool + + +def run_git(repo: Path, *arguments: str, text: bool = True) -> str | bytes: + result = subprocess.run( + ["git", "-C", str(repo), *arguments], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + text=text, + ) + if result.returncode != 0: + detail = result.stderr.strip() if text else result.stderr.decode(errors="replace").strip() + raise RuntimeError(f"git {' '.join(arguments)} failed: {detail}") + return result.stdout + + +def repository_root(configured: str | None) -> Path: + candidate = Path(configured or Path.cwd()).expanduser().resolve() + output = subprocess.run( + ["git", "-C", str(candidate), "rev-parse", "--show-toplevel"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + if output.returncode != 0: + raise RuntimeError(f"{candidate} is not inside a Git repository") + return Path(output.stdout.strip()).resolve() + + +def load_release_identity(repo: Path) -> ReleaseIdentity: + module_path = repo / "magicai" / "versioning.py" + spec = importlib.util.spec_from_file_location("magicai_release_versioning", module_path) + if spec is None or spec.loader is None: + raise RuntimeError(f"Unable to load {module_path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + pyproject = tomllib.loads((repo / "pyproject.toml").read_text(encoding="utf-8")) + package_version = str(pyproject["project"]["version"]) + expected_package_version = str(module.PACKAGE_FALLBACK_VERSION) + if package_version != expected_package_version: + raise RuntimeError( + "Version mismatch: pyproject.toml declares " + f"{package_version!r}, but magicai/versioning.py declares " + f"{expected_package_version!r}." + ) + + return ReleaseIdentity( + public_version=str(module.PUBLIC_VERSION), + package_version=package_version, + tag=str(module.RELEASE_TAG), + channel=str(module.RELEASE_CHANNEL), + codename=str(module.RELEASE_CODENAME), + ) + + +def tracked_paths(repo: Path) -> list[PurePosixPath]: + raw = run_git(repo, "ls-files", "-z", text=False) + assert isinstance(raw, bytes) + paths = [] + for value in raw.split(b"\0"): + if not value: + continue + paths.append(PurePosixPath(value.decode("utf-8", errors="strict"))) + return sorted(paths, key=str) + + +def should_exclude(path: PurePosixPath) -> bool: + value = path.as_posix() + if any(part in EXCLUDED_PARTS for part in path.parts): + return True + return any(value == prefix.rstrip("/") or value.startswith(prefix) for prefix in EXCLUDED_PREFIXES) + + +def collect_entries(repo: Path, mode: str) -> list[PackageEntry]: + entries: dict[PurePosixPath, PackageEntry] = {} + + for relative in tracked_paths(repo): + if should_exclude(relative): + continue + if mode == "source" and relative in FULL_SOURCE_FILES: + continue + source = repo / relative + if not source.is_file(): + raise RuntimeError(f"Tracked file is missing from the working tree: {relative}") + if source.is_symlink(): + raise RuntimeError(f"Release packages do not include symbolic links: {relative}") + executable = bool(source.stat().st_mode & stat.S_IXUSR) + entries[relative] = PackageEntry(relative, source, executable) + + if mode == "full": + missing = [path for path in FULL_SOURCE_FILES if not (repo / path).is_file()] + if missing: + formatted = ", ".join(str(path) for path in missing) + raise RuntimeError( + "Full package requires local bulk sources. Missing: " + formatted + ) + for relative in FULL_SOURCE_FILES: + source = repo / relative + entries[relative] = PackageEntry(relative, source, False) + + return [entries[path] for path in sorted(entries, key=str)] + + +def sha256_bytes(content: bytes) -> str: + return hashlib.sha256(content).hexdigest() + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def source_epoch(repo: Path) -> int: + configured = os.environ.get("SOURCE_DATE_EPOCH") + if configured: + try: + return max(int(configured), 315532800) + except ValueError as error: + raise RuntimeError("SOURCE_DATE_EPOCH must be an integer") from error + + output = run_git(repo, "log", "-1", "--format=%ct") + assert isinstance(output, str) + return max(int(output.strip()), 315532800) + + +def zip_datetime(epoch: int) -> tuple[int, int, int, int, int, int]: + value = datetime.fromtimestamp(epoch, tz=timezone.utc) + second = value.second - (value.second % 2) + return value.year, value.month, value.day, value.hour, value.minute, second + + +def git_metadata(repo: Path) -> dict[str, str]: + commit = str(run_git(repo, "rev-parse", "HEAD")).strip() + branch = str(run_git(repo, "branch", "--show-current")).strip() or "detached" + status = str(run_git(repo, "status", "--porcelain", "--untracked-files=no")) + return { + "commit": commit, + "branch": branch, + "tracked_worktree": "clean" if not status.strip() else "modified", + } + + +def build_metadata( + *, + repo: Path, + identity: ReleaseIdentity, + mode: str, + entries: list[PackageEntry], + epoch: int, +) -> tuple[bytes, bytes]: + git_info = git_metadata(repo) + files = [] + for entry in entries: + files.append( + { + "path": entry.relative_path.as_posix(), + "size": entry.source_path.stat().st_size, + "sha256": sha256_file(entry.source_path), + } + ) + + generated_at = datetime.fromtimestamp(epoch, tz=timezone.utc).isoformat() + manifest = { + "schema_version": "1.0", + "project": "MagicAI", + "package_mode": mode, + "public_version": identity.public_version, + "package_version": identity.package_version, + "release_tag": identity.tag, + "release_channel": identity.channel, + "release_codename": identity.codename, + "generated_at": generated_at, + "git": git_info, + "files": files, + } + manifest_bytes = ( + json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n" + ).encode("utf-8") + + info = "\n".join( + [ + "MagicAI release package", + "", + f"Public version: {identity.public_version}", + f"Package version: {identity.package_version}", + f"Codename: {identity.codename}", + f"Channel: {identity.channel}", + f"Tag: {identity.tag}", + f"Package mode: {mode}", + f"Git branch: {git_info['branch']}", + f"Git commit: {git_info['commit']}", + f"Tracked worktree: {git_info['tracked_worktree']}", + f"Generated from commit time: {generated_at}", + f"Packaged files: {len(entries)}", + "", + "See PACKAGE_MANIFEST.json for per-file SHA-256 values.", + "", + ] + ).encode("utf-8") + return info, manifest_bytes + + +def write_zip_entry( + archive: zipfile.ZipFile, + archive_name: str, + content: bytes, + *, + timestamp: tuple[int, int, int, int, int, int], + executable: bool = False, +) -> None: + info = zipfile.ZipInfo(archive_name, date_time=timestamp) + info.compress_type = zipfile.ZIP_DEFLATED + info.create_system = 3 + mode = 0o755 if executable else 0o644 + info.external_attr = (stat.S_IFREG | mode) << 16 + archive.writestr(info, content, compresslevel=9) + + +def build_archive( + *, + repo: Path, + mode: str, + output_dir: Path, + force: bool, + allow_dirty: bool = False, +) -> Path: + identity = load_release_identity(repo) + current_git = git_metadata(repo) + if current_git["tracked_worktree"] != "clean" and not allow_dirty: + raise RuntimeError( + "Tracked files are modified. Commit or restore them before packaging, " + "or use --allow-dirty for a diagnostic archive." + ) + entries = collect_entries(repo, mode) + epoch = source_epoch(repo) + timestamp = zip_datetime(epoch) + root_name = f"MagicAI-v{identity.public_version}-{mode}" + output_dir.mkdir(parents=True, exist_ok=True) + archive_path = output_dir / f"{root_name}.zip" + checksum_path = archive_path.with_suffix(archive_path.suffix + ".sha256") + + if (archive_path.exists() or checksum_path.exists()) and not force: + raise RuntimeError( + f"Output already exists: {archive_path}. Use --force to replace it." + ) + + info_bytes, manifest_bytes = build_metadata( + repo=repo, + identity=identity, + mode=mode, + entries=entries, + epoch=epoch, + ) + + temporary = archive_path.with_suffix(".zip.tmp") + temporary.unlink(missing_ok=True) + try: + with zipfile.ZipFile(temporary, "w", allowZip64=True) as archive: + for entry in entries: + write_zip_entry( + archive, + f"{root_name}/{entry.relative_path.as_posix()}", + entry.source_path.read_bytes(), + timestamp=timestamp, + executable=entry.executable, + ) + write_zip_entry( + archive, + f"{root_name}/INFO.txt", + info_bytes, + timestamp=timestamp, + ) + write_zip_entry( + archive, + f"{root_name}/PACKAGE_MANIFEST.json", + manifest_bytes, + timestamp=timestamp, + ) + temporary.replace(archive_path) + finally: + temporary.unlink(missing_ok=True) + + checksum = sha256_file(archive_path) + checksum_path.write_text(f"{checksum} {archive_path.name}\n", encoding="utf-8") + return archive_path + + +def parse_arguments(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument("--source", action="store_true", help="Build the clean source package") + mode.add_argument("--full", action="store_true", help="Build the full package with local bulk sources") + parser.add_argument("--repo", help="Repository path; defaults to the current repository") + parser.add_argument( + "--output-dir", + default="dist/releases", + help="Output directory relative to the repository unless absolute", + ) + parser.add_argument("--force", action="store_true", help="Replace an existing archive") + parser.add_argument( + "--allow-dirty", + action="store_true", + help="Allow packaging modified tracked files for diagnostics", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + arguments = parse_arguments(argv or sys.argv[1:]) + try: + repo = repository_root(arguments.repo) + output_dir = Path(arguments.output_dir).expanduser() + if not output_dir.is_absolute(): + output_dir = repo / output_dir + mode = "full" if arguments.full else "source" + archive = build_archive( + repo=repo, + mode=mode, + output_dir=output_dir.resolve(), + force=arguments.force, + allow_dirty=arguments.allow_dirty, + ) + except (OSError, RuntimeError, ValueError, KeyError) as error: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + + checksum_path = archive.with_suffix(archive.suffix + ".sha256") + print(f"Created: {archive}") + print(f"Checksum: {checksum_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/update_scryfall_symbology.py b/scripts/update_scryfall_symbology.py index e8d6e73..7d7d920 100644 --- a/scripts/update_scryfall_symbology.py +++ b/scripts/update_scryfall_symbology.py @@ -1,61 +1,61 @@ -import json -import urllib.request -from pathlib import Path - - -PROJECT_ROOT = Path(__file__).resolve().parent.parent - -OUTPUT_FILE = ( - PROJECT_ROOT - / "sources" - / "scryfall" - / "symbology.json" -) - -SCRYFALL_SYMBOLOGY_URL = "https://api.scryfall.com/symbology" - - -def main(): - - OUTPUT_FILE.parent.mkdir( - parents=True, - exist_ok=True, - ) - - request = urllib.request.Request( - SCRYFALL_SYMBOLOGY_URL, - headers={ - "User-Agent": "MagicAI/0.1.1-beta", - "Accept": "application/json", - }, - ) - - with urllib.request.urlopen(request, timeout=30) as response: - - payload = json.loads( - response.read().decode("utf-8") - ) - - OUTPUT_FILE.write_text( - json.dumps( - payload, - ensure_ascii=False, - indent=2, - ), - encoding="utf-8", - ) - - count = len( - payload.get( - "data", - [], - ) - ) - - print(f"Wrote {OUTPUT_FILE}") - print(f"Symbols: {count}") - - -if __name__ == "__main__": - +import json +import urllib.request +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent + +OUTPUT_FILE = ( + PROJECT_ROOT + / "sources" + / "scryfall" + / "symbology.json" +) + +SCRYFALL_SYMBOLOGY_URL = "https://api.scryfall.com/symbology" + + +def main(): + + OUTPUT_FILE.parent.mkdir( + parents=True, + exist_ok=True, + ) + + request = urllib.request.Request( + SCRYFALL_SYMBOLOGY_URL, + headers={ + "User-Agent": "MagicAI/0.1.1-beta", + "Accept": "application/json", + }, + ) + + with urllib.request.urlopen(request, timeout=30) as response: + + payload = json.loads( + response.read().decode("utf-8") + ) + + OUTPUT_FILE.write_text( + json.dumps( + payload, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + + count = len( + payload.get( + "data", + [], + ) + ) + + print(f"Wrote {OUTPUT_FILE}") + print(f"Symbols: {count}") + + +if __name__ == "__main__": + main() \ No newline at end of file From 641fa82228643a292fe9eed2f9df03910d774fbc Mon Sep 17 00:00:00 2001 From: Manuel David Villalba Escamilla Date: Wed, 15 Jul 2026 11:50:58 +0200 Subject: [PATCH 3/7] Sprint 12.2a - Add executable Judge Tool Gateway --- docs/API_CONTRACT.md | 111 ++-- docs/ARCHITECTURE.md | 43 +- docs/COMMANDS.md | 240 ++++---- docs/JUDGE_TOOL_GATEWAY.md | 97 ++++ docs/ROADMAP.md | 263 +++++---- docs/STATUS.md | 96 ++- docs/TACTICIAN.md | 34 +- magicai/api/routes.py | 547 ++++++++++-------- magicai/api/schemas.py | 361 ++++++------ magicai/judge_tools/__init__.py | 16 + magicai/judge_tools/budget.py | 67 +++ magicai/judge_tools/gateway.py | 285 +++++++++ magicai/judge_tools/models.py | 87 +++ magicai/judge_tools/registry.py | 60 +- magicai/judge_tools/tools/__init__.py | 15 + magicai/judge_tools/tools/common.py | 89 +++ magicai/judge_tools/tools/conversation.py | 50 ++ magicai/judge_tools/tools/legality.py | 66 +++ magicai/judge_tools/tools/oracle.py | 73 +++ magicai/judge_tools/tools/rules.py | 75 +++ magicai/judge_tools/tools/rulings.py | 70 +++ magicai/tactician/core.py | 176 +++++- magicai/tactician/models.py | 4 + magicai/ui/static/app.js | 2 + magicai/versioning.py | 81 +-- scripts/ci_check.py | 4 + tests/api/judge_tool_api_test.py | 68 +++ tests/judge_tools/__init__.py | 1 + tests/judge_tools/judge_tool_gateway_test.py | 94 +++ tests/judge_tools/local_tools_test.py | 147 +++++ tests/tactician/tactician_core_test.py | 36 +- .../tactician/tactician_tool_gateway_test.py | 85 +++ 32 files changed, 2591 insertions(+), 852 deletions(-) create mode 100644 docs/JUDGE_TOOL_GATEWAY.md create mode 100644 magicai/judge_tools/budget.py create mode 100644 magicai/judge_tools/gateway.py create mode 100644 magicai/judge_tools/models.py create mode 100644 magicai/judge_tools/tools/__init__.py create mode 100644 magicai/judge_tools/tools/common.py create mode 100644 magicai/judge_tools/tools/conversation.py create mode 100644 magicai/judge_tools/tools/legality.py create mode 100644 magicai/judge_tools/tools/oracle.py create mode 100644 magicai/judge_tools/tools/rules.py create mode 100644 magicai/judge_tools/tools/rulings.py create mode 100644 tests/api/judge_tool_api_test.py create mode 100644 tests/judge_tools/__init__.py create mode 100644 tests/judge_tools/judge_tool_gateway_test.py create mode 100644 tests/judge_tools/local_tools_test.py create mode 100644 tests/tactician/tactician_tool_gateway_test.py diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index b145e87..de13603 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -1,57 +1,54 @@ -# MagicAI HTTP contract - -## Versions - -- API contract: `1.2` -- JudgeResult schema: `1.0` -- TacticianResult schema: `0.2` - -## Endpoints - -```text -GET / service metadata -GET /meta contracts, profiles, codenames, capabilities -GET /health source and Ollama health -POST /ask Judge with optional automatic handoff -POST /tactician/ask explicit Tactician request -GET /conversations local history -GET /conversations/{id} conversation detail -PATCH /conversations/{id} rename conversation -DELETE /conversations/{id} delete conversation -``` - -## Ask request - -```json -{ - "question": "And does it combo with Ghave and Ashnod's Altar?", - "session_id": "optional-session-id", - "auto_handoff": true -} -``` - -`auto_handoff` defaults to `true`. Set it to `false` only for diagnostics that need the raw `strategy_required` Judge boundary. - -## Strategic response fields - -A handoff response remains compatible with the Judge evidence fields and may add: - -```json -{ - "authority": "tactician", - "strategy_intent": "combo_detection", - "combo_classification": "infinite_combo", - "combo_steps": [], - "outcomes": [], - "synergies": [], - "risks": [], - "inherited_cards": ["Young Wolf"], - "judge_queries": [], - "judge_result": {} -} -``` - - -## Release metadata - -`GET /meta` and `GET /health` expose the public version, PEP 440 package version, release channel, codename, and canonical Git tag. The current public identity is `v0.1.1-beta` — **Force of Will**. +# API contract + +Current API contract version: `1.4`. + +## Stable result families + +- Judge results use JudgeResult schema `1.0`. +- Tactician results use TacticianResult schema `0.3`. +- Judge tool results use JudgeToolResult schema `1.0`. + +## Main endpoints + +- `GET /` +- `GET /meta` +- `GET /health` +- `POST /ask` +- `POST /tactician/ask` +- `POST /judge/tools/execute` +- conversation history endpoints under `/conversations` + +## Judge Tool Gateway endpoint + +`POST /judge/tools/execute` executes one read-only Judge-owned capability. + +Example: + +```json +{ + "tool": "rules_lookup", + "arguments": { + "identifiers": ["702.93", "701.21"] + }, + "purpose": "verify_undying_sacrifice", + "result_limit": 8 +} +``` + +The endpoint is intended for local diagnostics, future UI tooling, and controlled strategic orchestration. It does not allow arbitrary files, shell commands, provider URLs, or network access. + +A `session_id` may be supplied only when `conversation_context` requires an existing persisted conversation. + +## Capability discovery + +`GET /meta` exposes: + +- executable status; +- authority level; +- provider; +- read-only status; +- accepted argument names; +- result limit; +- planned and permission-gated capabilities. + +Unavailable capabilities return a structured result instead of being guessed. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 68cc730..4034bc2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -4,7 +4,7 @@ > The Judge owns factual authority and source access. The Tactician owns strategic investigation. The Critic rejects unsupported conclusions. -The source boundary is a trust boundary. It must not prevent the Tactician from asking repeated, increasingly precise questions. +The source boundary is a trust boundary, not a one-shot limitation. The Tactician may issue repeated structured requests through the Judge. ## Main components @@ -21,40 +21,49 @@ Request orchestrator │ ├─ source retrieval │ ├─ deterministic renderers │ ├─ LLM explanation fallback - │ └─ validation and safe fallback + │ ├─ validation and safe fallback + │ └─ Judge Tool Gateway + │ ├─ Oracle + │ ├─ Comprehensive Rules + │ ├─ rulings + │ ├─ legality + │ └─ conversation context │ └─ Tactician ├─ strategic intent classification ├─ evidence-gap detection ├─ combo and synergy analysis + ├─ Judge-tool requests ├─ Judge challenges └─ recommended lines and risks ``` -## Judge source gateway +## Judge Tool Gateway -The Judge exposes capabilities instead of exposing raw source access to strategic profiles. Current and planned capabilities are published by `GET /meta`. +The gateway converts source adapters into typed, read-only capabilities. Every result records authority, provider, source version, arguments, purpose, latency, cache state, warnings, and budget state. -Authority levels: +Current executable authority levels: -- `official_card_data`: Scryfall Oracle snapshot. -- `official_rules`: Comprehensive Rules snapshot. -- `official_rulings`: Scryfall rulings snapshot. -- `community_combo_candidate`: Commander Spellbook candidate requiring revalidation. -- `statistical`: authorized EDHREC-style statistics, never rules authority. +- `official_card_data`: local Scryfall Oracle snapshot. +- `official_rules`: local Comprehensive Rules snapshot. +- `official_rulings`: local Scryfall rulings snapshot. +- `session_state`: bounded local conversation state. + +Future authority levels: + +- `community_combo_candidate`: Commander Spellbook candidate requiring full revalidation. +- `statistical`: authorized statistics, never rules authority. - `user_data`: local collection, deck, preferences, or metagame. +Planned providers remain visible but cannot execute until an authorized handler is installed. + ## Automatic handoff `POST /ask` first calls the Judge. When the Judge returns `strategy_required` and `auto_handoff=true`, the boundary answer is replaced by a Tactician result in the same conversation turn. -The previous active card package is preserved before the Judge processes the new turn. Referential questions such as: - -```text -And does it combo with Ghave and Ashnod's Altar? -``` +The previous active card package is preserved before the Judge processes the new turn. Referential questions can therefore inherit previously validated cards. -can therefore inherit the previously validated `Young Wolf` card package. +The Tactician can then refresh named cards through `oracle_lookup` without direct source access. ## Combo proof model @@ -78,4 +87,4 @@ Classification values: ## Resilience -The Tactician should continue investigating when evidence is incomplete. A later planner will use a bounded query budget rather than a fixed one-shot package. Missing tools must be reported through the capability registry, not silently guessed. +The Tactician should continue investigating when evidence is incomplete. The gateway supplies bounded query budgets, explicit capability failures, and source-aware caching. Missing tools must be reported rather than silently guessed. diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index d1ce11f..6dc5b98 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -1,104 +1,136 @@ -# MagicAI command reference - -## Environment - -```bash -cd ~/MagicAI -source .venv/bin/activate -export PYTHONPATH=. -``` - -## Start API and UI - -```bash -python -m uvicorn magicai.api:app --reload -``` - -## Source updates - -```bash -./scripts/download_sources.sh -./scripts/download_rules.sh -python scripts/update_scryfall_symbology.py -``` - -## Focused tests - -```bash -python -m tests.validation.rule_renderer_test -python -m tests.validation.oracle_renderer_test -python -m tests.validation.answer_validation_test -python -m tests.tactician.tactician_reviewer_test -python -m tests.tactician.tactician_strategy_test -python -m tests.tactician.tactician_conversation_handoff_test -python -m tests.api.tactician_api_contract_test -``` - -## Fast pull request checks - -```bash -python scripts/ci_check.py -``` - -## Release packages - -```bash -python scripts/package_release.py --source -python scripts/package_release.py --full -``` - -## GitHub repository analysis export - -```bash -./scripts/export_github_analysis.sh -``` - -## Dynamic Gauntlet - -```bash -python -m tests.quality.dynamic_gauntlet_test \ - --seed 184729 \ - --cases 42 -``` - -## Multi-seed campaign - -```bash -python -m tests.quality.dynamic_campaign_test \ - --base-seed 184729 \ - --runs 20 \ - --cases 50 \ - --workers 4 \ - --output-dir quality-results/dynamic-campaign \ - --require-full-coverage -``` - -Resume: - -```bash -python -m tests.quality.dynamic_campaign_test \ - --base-seed 184729 \ - --runs 20 \ - --cases 50 \ - --workers 4 \ - --output-dir quality-results/dynamic-campaign \ - --require-full-coverage \ - --resume -``` - -## Exhaustive Oracle audit - -```bash -python -u -m tests.quality.oracle_exhaustive_test \ - --workers 4 \ - --shard-size 250 \ - --output-dir quality-results/oracle-exhaustive -``` - -## Git checks - -```bash -git status --short -git diff --check -git diff --stat -``` +# MagicAI command reference + +## Environment + +```bash +cd ~/MagicAI +source .venv/bin/activate +export PYTHONPATH=. +``` + +## Start API and UI + +```bash +python -m uvicorn magicai.api:app --reload +``` + +## Source updates + +```bash +./scripts/download_sources.sh +./scripts/download_rules.sh +python scripts/update_scryfall_symbology.py +``` + +## Focused tests + +```bash +python -m tests.validation.rule_renderer_test +python -m tests.validation.oracle_renderer_test +python -m tests.validation.answer_validation_test +python -m tests.tactician.tactician_reviewer_test +python -m tests.tactician.tactician_strategy_test +python -m tests.tactician.tactician_conversation_handoff_test +python -m tests.api.tactician_api_contract_test +``` + +## Fast pull request checks + +```bash +python scripts/ci_check.py +``` + +## Release packages + +```bash +python scripts/package_release.py --source +python scripts/package_release.py --full +``` + +## GitHub repository analysis export + +```bash +./scripts/export_github_analysis.sh +``` + +## Dynamic Gauntlet + +```bash +python -m tests.quality.dynamic_gauntlet_test \ + --seed 184729 \ + --cases 42 +``` + +## Multi-seed campaign + +```bash +python -m tests.quality.dynamic_campaign_test \ + --base-seed 184729 \ + --runs 20 \ + --cases 50 \ + --workers 4 \ + --output-dir quality-results/dynamic-campaign \ + --require-full-coverage +``` + +Resume: + +```bash +python -m tests.quality.dynamic_campaign_test \ + --base-seed 184729 \ + --runs 20 \ + --cases 50 \ + --workers 4 \ + --output-dir quality-results/dynamic-campaign \ + --require-full-coverage \ + --resume +``` + +## Exhaustive Oracle audit + +```bash +python -u -m tests.quality.oracle_exhaustive_test \ + --workers 4 \ + --shard-size 250 \ + --output-dir quality-results/oracle-exhaustive +``` + +## Git checks + +```bash +git status --short +git diff --check +git diff --stat +``` + +## Judge Tool Gateway + +Inspect capabilities: + +```bash +curl -s http://127.0.0.1:8000/meta | python -m json.tool +``` + +Resolve Oracle evidence: + +```bash +curl -s -X POST http://127.0.0.1:8000/judge/tools/execute \ + -H 'Content-Type: application/json' \ + -d '{ + "tool": "oracle_lookup", + "arguments": {"card_names": ["Young Wolf"]}, + "purpose": "manual_gateway_check" + }' | python -m json.tool +``` + +Resolve rules: + +```bash +curl -s -X POST http://127.0.0.1:8000/judge/tools/execute \ + -H 'Content-Type: application/json' \ + -d '{ + "tool": "rules_lookup", + "arguments": {"identifiers": ["702.93", "701.21", "700.4"]}, + "purpose": "verify_undying_sacrifice" + }' | python -m json.tool +``` diff --git a/docs/JUDGE_TOOL_GATEWAY.md b/docs/JUDGE_TOOL_GATEWAY.md new file mode 100644 index 0000000..4037ac6 --- /dev/null +++ b/docs/JUDGE_TOOL_GATEWAY.md @@ -0,0 +1,97 @@ +# Judge Tool Gateway + +The Judge Tool Gateway is the controlled evidence interface used by strategic profiles. + +## Trust boundary + +The Tactician decides what must be investigated. The Judge owns every source adapter and returns structured, read-only evidence. + +The gateway does not make strategic decisions. It preserves: + +- provider identity; +- authority level; +- local source versions; +- arguments and purpose; +- latency; +- cache state; +- warnings and explicit failures; +- bounded investigation budgets. + +## Executable tools + +The first executable set is: + +- `oracle_lookup` +- `oracle_search` +- `rules_lookup` +- `rules_search` +- `rulings_lookup` +- `legality_check` +- `conversation_context` + +Planned or permission-gated providers remain visible in the capability registry but return an explicit unavailable result until an authorized adapter exists. + +## Request contract + +```json +{ + "tool": "oracle_lookup", + "arguments": { + "card_names": ["Young Wolf"] + }, + "purpose": "verify_combo_component", + "request_id": "optional-caller-id", + "result_limit": 8 +} +``` + +## Result contract + +```json +{ + "schema_version": "1.0", + "tool": "oracle_lookup", + "status": "success", + "authority": "official_card_data", + "provider": "local_scryfall_oracle", + "purpose": "verify_combo_component", + "arguments": { + "card_names": ["Young Wolf"] + }, + "evidence": [], + "source_versions": {}, + "warnings": [], + "metadata": {}, + "error_code": "", + "error_message": "", + "elapsed_ms": 0.0, + "cache_hit": false, + "budget": {} +} +``` + +## Budgets + +A strategic investigation can share one `JudgeToolBudget` across several calls. The budget limits: + +- total tool calls; +- calls per tool; +- repeated identical requests; +- elapsed investigation time. + +This prevents accidental loops without forcing the Tactician into a one-shot evidence package. + +## Cache + +Read-only source queries use a bounded in-memory cache keyed by: + +- tool name; +- normalized arguments; +- requested result limit; +- local source-version fingerprint. + +Conversation context is never cached. + +## REST diagnostics + +`POST /judge/tools/execute` exposes the same read-only contract for local diagnostics and future UI tooling. Strategic code should use `JudgeToolGateway` rather than opening files or repositories directly. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1663e75..a8de9e8 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,125 +1,138 @@ -# MagicAI roadmap - -## Release targets - -### 0.1.1 beta — Force of Will — current - -The first public beta establishes the integrated Judge and Tactician foundation, persistent conversations, structured evidence, automated handoff, and repository-health basics. - -### 0.2.0 beta — Ponder - -The next integrated beta requires: - -- reliable Judge answers for the covered families; -- source-grounded validation and safe failure; -- automatic Judge-to-Tactician handoff; -- resilient conversation continuity; -- iterative Tactician queries through the Judge tool gateway; -- formal combo reconstruction; -- useful local UI and persistent history; -- reproducible installation and evaluation. - -### 1.0 — NicolAI Bolas - -The first major release should add mature deck analysis, authorized strategic sources, collection awareness, broader format support, and production-grade packaging. - -## Repository health — parallel track - -- Fast pull request CI without Ollama or bulk sources. -- Clean tracked-file release packaging. -- Security, support, conduct, and pull request policies. -- Dependabot configuration. -- Explicit branch and release procedures. -- Later: test markers, static analysis, governance, maintainers, ADRs, and branch cleanup. - -## Sprint 12 — Tactician integration - -### 12.0 — Initial vertical slice — complete - -- Tactician profile and API endpoint. -- Judge contradiction review. -- Basic role and synergy analysis. - -### 12.1 — Automatic handoff and continuity — implemented - -- Automatic handoff from `/ask`. -- No duplicate conversation turns. -- Referential previous-card inheritance. -- Intent-specific combo boundary. -- Structured combo steps and outcomes. -- Generic Young Wolf + mana outlet + counter converter loop recognition. -- Judge capability registry. - -### 12.2 — Judge tool gateway - -- Typed tool requests and responses. -- Per-tool provenance, version, latency, and authority. -- Legality exposed to Tactician evidence. -- Query cache and budgets. -- Capability-missing responses. - -### 12.3 — Autonomous investigation planner - -- Hypothesis decomposition. -- Multiple Judge queries per user request. -- Evidence sufficiency scoring. -- Alternative and counterexample search. -- Bounded time and query budgets. -- Full investigation trace. - -### 12.4 — Formal combo verifier - -- State graph. -- Costs and resources. -- Zones, targets, timing, and priority. -- State restoration. -- Net output. -- Interruption points. -- Infinite, bounded, synergy, and non-combo classifications. - -### 12.5 — Commander Spellbook connector - -- Official or permitted API client. -- Local cache and provenance. -- Candidate retrieval by cards or deck. -- Mandatory Oracle and rules revalidation. -- Drift detection. - -### 12.6 — Deck analysis - -- Curve, lands, and color sources. -- Ramp, card advantage, interaction, and protection. -- Engines, win conditions, and redundancy. -- Existing and near-complete combos. -- Budget and collection-aware alternatives. - -### 12.7 — Authorized strategic statistics - -- Provider abstraction. -- Authorized EDHREC integration or explicit user import. -- Popularity and synergy statistics clearly labeled as non-authoritative. - -### 12.8 — User context - -- Format and Commander bracket. -- Budget. -- Collection. -- Preferred play style. -- Local metagame. -- Infinite-combo preferences. - -### 12.9 — Critic integration - -- Structured challenges. -- Evidence contradiction checks. -- Second validation. -- Source-built fallback. - -### 12.10 — Strategic Gauntlets - -- Handoff Gauntlet. -- Conversation Reference Gauntlet. -- Combo Verification Gauntlet. -- Synergy Classification Gauntlet. -- Spellbook Drift Gauntlet. -- Judge–Tactician Disagreement Gauntlet. +# MagicAI roadmap + +## Release targets + +### 0.1.1 beta — Force of Will — current + +The first public beta establishes the integrated Judge and Tactician foundation, persistent conversations, structured evidence, automated handoff, and repository-health basics. + +### 0.2.0 beta — Ponder + +The next integrated beta requires: + +- reliable Judge answers for the covered families; +- source-grounded validation and safe failure; +- automatic Judge-to-Tactician handoff; +- resilient conversation continuity; +- iterative Tactician queries through the Judge Tool Gateway; +- formal combo reconstruction; +- useful local UI and persistent history; +- reproducible installation and evaluation. + +### 1.0 — NicolAI Bolas + +The first major release should add mature deck analysis, authorized strategic sources, collection awareness, broader format support, and production-grade packaging. + +## Repository health — parallel track + +- Fast pull request CI without Ollama or bulk sources. +- Clean tracked-file release packaging. +- Security, support, conduct, and pull request policies. +- Dependabot configuration. +- Explicit branch and release procedures. +- Later: test markers, static analysis, governance, maintainers, ADRs, and branch cleanup. + +## Sprint 12 — Tactician integration + +### 12.0 — Initial vertical slice — complete + +- Tactician profile and API endpoint. +- Judge contradiction review. +- Basic role and synergy analysis. + +### 12.1 — Automatic handoff and continuity — complete + +- Automatic handoff from `/ask`. +- No duplicate conversation turns. +- Referential previous-card inheritance. +- Intent-specific combo boundary. +- Structured combo steps and outcomes. +- Generic Undying loop recognition. +- Judge capability registry. + +### 12.2a — Executable Judge Tool Gateway — complete + +- Typed tool requests and responses. +- Per-tool provenance, version, latency, cache state, and authority. +- Executable Oracle lookup and search. +- Executable rules lookup and search. +- Executable rulings, legality, and conversation-context tools. +- Bounded shared query budgets. +- Capability-missing responses. +- Read-only diagnostic REST endpoint. +- Initial Tactician Oracle refresh through the gateway. + +### 12.2b — Conversational strategic orchestration — next + +- `play_sequence`, `combo_execution_order`, `combo_disruption`, and related intents. +- Active strategic conversation state. +- Independent Tactician synthesis instead of Judge-answer relay. +- Warm, interactive response style. +- Smart ambiguity handling. +- Factual claim verification after synthesis. + +### 12.3 — Autonomous investigation planner + +- Hypothesis decomposition. +- Multiple Judge queries per user request. +- Evidence sufficiency scoring. +- Alternative and counterexample search. +- Bounded time and query budgets. +- Full investigation trace. + +### 12.4 — Formal combo verifier + +- State graph. +- Costs and resources. +- Zones, targets, timing, and priority. +- State restoration. +- Net output. +- Interruption points. +- Infinite, bounded, synergy, and non-combo classifications. + +### 12.5 — Commander Spellbook connector + +- Official or permitted API client. +- Local cache and provenance. +- Candidate retrieval by cards or deck. +- Mandatory Oracle and rules revalidation. +- Drift detection. + +### 12.6 — Deck analysis + +- Curve, lands, and color sources. +- Ramp, card advantage, interaction, and protection. +- Engines, win conditions, and redundancy. +- Existing and near-complete combos. +- Budget and collection-aware alternatives. + +### 12.7 — Authorized strategic statistics + +- Provider abstraction. +- Authorized EDHREC integration or explicit user import. +- Popularity and synergy statistics clearly labeled as non-authoritative. + +### 12.8 — User context + +- Format and Commander bracket. +- Budget. +- Collection. +- Preferred play style. +- Local metagame. +- Infinite-combo preferences. + +### 12.9 — Critic integration + +- Structured challenges. +- Evidence contradiction checks. +- Second validation. +- Source-built fallback. + +### 12.10 — Strategic Gauntlets + +- Handoff Gauntlet. +- Conversation Reference Gauntlet. +- Combo Verification Gauntlet. +- Synergy Classification Gauntlet. +- Spellbook Drift Gauntlet. +- Judge–Tactician Disagreement Gauntlet. diff --git a/docs/STATUS.md b/docs/STATUS.md index 9d9a2ba..2bf0dcb 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,52 +1,44 @@ -# Current MagicAI status - -> Current release: `v0.1.1-beta` — **Force of Will** -> Next beta milestone: `v0.2.0-beta` — **Ponder** -> Planned 1.0 codename: **NicolAI Bolas** - -## Overall assessment - -MagicAI is an early public beta with an advanced Judge core and an integrated Tactician foundation. - -Approximate maturity relative to the complete vision: - -| Area | Maturity | -|---|---:| -| Local Oracle, rules, rulings, and source health | 85% | -| Evidence retrieval and provenance | 75% | -| Deterministic Judge coverage | 70% | -| LLM validation and repair | 50% | -| Evaluation infrastructure | 80% | -| Local UI and persistent history | 65% | -| Tactician handoff and continuity | 35% | -| Formal combo engine | 20% | -| Full deck analysis | 5% | -| Commander Spellbook | 0% | -| Authorized statistics | 0% | - -These are planning estimates, not coverage statistics. - -## Current strengths - -- The model is not the sole authority. -- Sources are local and versioned. -- Deterministic routes avoid unnecessary LLM calls. -- Evaluation campaigns are reproducible and resumable. -- Human audit can expose false positives after harness PASS results. -- Problems are fixed by semantic families rather than card-name exceptions. -- Strategic questions can now hand off automatically to the Tactician. -- Follow-up card context can survive the handoff. - -## Current blockers before Ponder - -- Multi-query autonomous Tactician planning is not implemented yet. -- The Judge tool gateway is a registry, not yet a complete typed execution API. -- Arbitrary combos are not formally proven through a general state graph. -- Validation can still miss unsupported LLM interpretations outside covered contracts. -- Spellbook, authorized statistics, user collection, and metagame sources are not connected. -- The full exhaustive evaluation must be run on the user's local Oracle snapshot after each major semantic change. - - -## Repository health - -The first repository-health foundation adds fast pull request CI, clean release packaging, community policies, dependency update configuration, and explicit branching and release documentation. Large Oracle and Ollama campaigns remain separate from fast CI. +# Project status + +## Release + +- Public version: `0.1.1-beta` +- Codename: `Force of Will` +- Next beta: `0.2.0-beta — Ponder` +- Future major release: `1.0 — NicolAI Bolas` + +## Current development line + +Sprint `12.2a` introduces the executable Judge Tool Gateway. + +Completed in this milestone: + +- typed read-only tool contracts; +- executable Oracle, rules, rulings, legality, and conversation-context tools; +- source provenance and version reporting; +- bounded shared investigation budgets; +- in-memory source-aware cache; +- explicit unavailable responses for planned providers; +- diagnostic REST endpoint; +- first Tactician evidence refresh through the gateway; +- focused source-independent CI coverage. + +## Next development line + +Sprint `12.2b` will focus on conversational strategic orchestration: + +- play-sequence and combo-follow-up intents; +- active strategic state; +- independent Tactician synthesis; +- warmer interactive responses; +- smart ambiguity handling; +- factual claim verification after synthesis. + +## Known limitations + +- The Tactician does not yet plan arbitrary multi-query investigations. +- Combo reconstruction covers only a narrow generic family. +- Commander Spellbook is not connected. +- EDHREC-style statistics remain permission-gated. +- A full deck analyzer and collection provider are not yet implemented. +- Some Judge fallbacks may still require stronger second-pass validation. diff --git a/docs/TACTICIAN.md b/docs/TACTICIAN.md index 54fc79f..94ce639 100644 --- a/docs/TACTICIAN.md +++ b/docs/TACTICIAN.md @@ -13,9 +13,9 @@ The Tactician analyzes: - risks, interaction points, and alternatives; - format, bracket, budget, collection, and local-metagame constraints. -It does not open Oracle, rules, rulings, Commander Spellbook, EDHREC, or user collection files directly. It asks the Judge-owned source gateway for structured evidence. +It does not open Oracle, rules, rulings, Commander Spellbook, EDHREC, or user collection files directly. It requests structured evidence through the Judge Tool Gateway. -## Current milestone: 0.2 +## Current milestone: 0.3 Implemented: @@ -24,12 +24,12 @@ Implemented: - strategic intent classification; - previous-turn card inheritance; - structured `strategy_intent`, `combo_classification`, `combo_steps`, and `outcomes`; -- generic recognition of a three-piece loop consisting of: - - a creature with Undying; - - a sacrifice outlet that generates mana; - - an ability that removes the +1/+1 counter and creates a token; +- generic recognition of a three-piece Undying loop; - Judge review challenges for contradictory LLM answers; -- Judge capability registry. +- executable, typed Judge Tool Gateway; +- bounded Oracle refresh through the Judge before strategic synthesis; +- tool-call provenance in `judge_tool_calls` and `judge_queries`; +- explicit `tactician_synthesized` metadata. ## Evidence loop @@ -37,16 +37,28 @@ Target design: ```text Tactician forms a hypothesis - → asks the Judge for evidence + → requests one or more Judge tools → checks gaps and contradictions - → asks follow-up questions + → requests narrower evidence → constructs a line - → Judge validates every factual claim + → Judge validates factual claims → Critic attempts to break the result → publish or declare uncertainty ``` -The current implementation records a first `judge_queries` trace. Multi-query planning is the next milestone. +Milestone 0.3 provides the executable gateway and a first bounded Oracle refresh. Autonomous multi-query planning remains the next milestone. + +## Personality + +The Judge should remain formal and concise. The Tactician should be warmer, conversational, and proactive while preserving the same factual boundary. + +It should: + +- explain why a line is attractive; +- mention risks and interaction windows; +- infer likely follow-up meaning from active strategic context; +- ask a question only when missing information materially changes the recommendation; +- never invent card text or rules to sound helpful. ## Constraints diff --git a/magicai/api/routes.py b/magicai/api/routes.py index 3dbee91..be4a114 100644 --- a/magicai/api/routes.py +++ b/magicai/api/routes.py @@ -1,253 +1,294 @@ -import sqlite3 - -from fastapi import APIRouter, HTTPException, Query - -from magicai.assistant import MagicAI -from magicai.api.health import build_health_payload -from magicai.api.schemas import ( - AskRequest, - AskResponse, - ConversationDeleteResponse, - ConversationDetailResponse, - ConversationRenameRequest, - ConversationSummaryResponse, - HealthResponse, - MetaResponse, - TacticianAskResponse, -) -from magicai.conversation.manager import ConversationManager -from magicai.tactician.core import Tactician, replace_boundary_answer -from magicai.judge_tools import get_capability_registry_payload -from magicai.judge_result import ( - JudgeConfidence, - JudgeOrigin, - JudgeStatus, -) -from magicai.versioning import ( - API_CONTRACT_VERSION, - JUDGE_RESULT_SCHEMA_VERSION, - get_project_version, - get_package_version, - TACTICIAN_RESULT_SCHEMA_VERSION, - RELEASE_CHANNEL, - RELEASE_CODENAME, - RELEASE_TAG, - NEXT_BETA_VERSION, - NEXT_BETA_CODENAME, - V1_CODENAME, -) - - -router = APIRouter() - -assistant = MagicAI() -tactician = Tactician(judge=assistant) -conversation_manager = ConversationManager() - - -@router.get("/") -def root(): - return { - "status": "ok", - "project": "MagicAI", - "project_version": get_project_version(), - "package_version": get_package_version(), - "release_channel": RELEASE_CHANNEL, - "release_codename": RELEASE_CODENAME, - "release_tag": RELEASE_TAG, - "api_contract_version": API_CONTRACT_VERSION, - "judge_result_schema_version": JUDGE_RESULT_SCHEMA_VERSION, - "ui": "/ui", - } - - -@router.get("/meta", response_model=MetaResponse) -def meta(): - return { - "project": "MagicAI", - "project_version": get_project_version(), - "package_version": get_package_version(), - "release_channel": RELEASE_CHANNEL, - "release_codename": RELEASE_CODENAME, - "release_tag": RELEASE_TAG, - "api_contract_version": API_CONTRACT_VERSION, - "judge_result_schema_version": JUDGE_RESULT_SCHEMA_VERSION, - "authority": "judge", - "judge_statuses": [item.value for item in JudgeStatus], - "judge_origins": [item.value for item in JudgeOrigin], - "confidence_levels": [item.value for item in JudgeConfidence], - "profiles": ["judge", "tactician"], - "tactician_result_schema_version": TACTICIAN_RESULT_SCHEMA_VERSION, - "next_beta_version": NEXT_BETA_VERSION, - "next_beta_codename": NEXT_BETA_CODENAME, - "v1_codename": V1_CODENAME, - "judge_capabilities": get_capability_registry_payload(), - } - - -@router.get("/health", response_model=HealthResponse) -def health(): - return build_health_payload() - - -@router.post("/ask", response_model=AskResponse) -def ask(request: AskRequest): - session_id, conversation = conversation_manager.get_or_create( - request.session_id - ) - - prior_cards = list(conversation.active_cards) - result = assistant.ask_result( - conversation, - request.question, - ) - - if request.auto_handoff and result.status is JudgeStatus.STRATEGY_REQUIRED: - result = tactician.from_judge_result( - question=request.question, - judge_result=result, - prior_cards=prior_cards, - ) - replace_boundary_answer(conversation, result) - - payload = result.to_dict() - try: - conversation_manager.save( - session_id, - conversation, - last_result={ - "session_id": session_id, - **payload, - }, - ) - except (OSError, sqlite3.Error): - payload["warnings"] = [ - *payload.get("warnings", []), - "La respuesta es válida, pero no se pudo guardar esta conversación en el historial local.", - ] - - return AskResponse( - session_id=session_id, - **payload, - ) - - - - -@router.post("/tactician/ask", response_model=TacticianAskResponse) -def tactician_ask(request: AskRequest): - session_id, conversation = conversation_manager.get_or_create( - request.session_id - ) - - result = tactician.ask_result( - conversation, - request.question, - ) - payload = result.to_dict() - - try: - conversation_manager.save( - session_id, - conversation, - last_result={ - "session_id": session_id, - **payload, - }, - ) - except (OSError, sqlite3.Error): - payload["warnings"] = [ - *payload.get("warnings", []), - "La respuesta es válida, pero no se pudo guardar esta conversación en el historial local.", - ] - - return TacticianAskResponse( - session_id=session_id, - **payload, - ) - - -@router.get( - "/conversations", - response_model=list[ConversationSummaryResponse], -) -def list_conversations( - limit: int = Query(default=50, ge=1, le=200), -): - return [ - _summary_response(summary) - for summary in conversation_manager.list(limit=limit) - ] - - -@router.get( - "/conversations/{session_id}", - response_model=ConversationDetailResponse, -) -def get_conversation(session_id: str): - record = conversation_manager.load(session_id) - if not record: - raise _conversation_not_found() - - summary = record.summary - return ConversationDetailResponse( - session_id=summary.session_id, - title=summary.title, - created_at=summary.created_at, - updated_at=summary.updated_at, - message_count=summary.message_count, - messages=[ - {"role": message.role, "content": message.content} - for message in record.conversation.history - ], - last_result=record.last_result, - ) - - -@router.patch( - "/conversations/{session_id}", - response_model=ConversationSummaryResponse, -) -def rename_conversation( - session_id: str, - request: ConversationRenameRequest, -): - summary = conversation_manager.rename(session_id, request.title) - if not summary: - raise _conversation_not_found() - return _summary_response(summary) - - -@router.delete( - "/conversations/{session_id}", - response_model=ConversationDeleteResponse, -) -def delete_conversation(session_id: str): - deleted = conversation_manager.delete(session_id) - if not deleted: - raise _conversation_not_found() - return ConversationDeleteResponse( - session_id=session_id, - deleted=True, - ) - - -def _summary_response(summary) -> ConversationSummaryResponse: - return ConversationSummaryResponse( - session_id=summary.session_id, - title=summary.title, - created_at=summary.created_at, - updated_at=summary.updated_at, - message_count=summary.message_count, - ) - - -def _conversation_not_found() -> HTTPException: - return HTTPException( - status_code=404, - detail={ - "code": "conversation_not_found", - "message": "The requested local conversation does not exist.", - "retryable": False, - }, - ) +import sqlite3 + +from fastapi import APIRouter, HTTPException, Query + +from magicai.assistant import MagicAI +from magicai.api.health import build_health_payload +from magicai.api.schemas import ( + AskRequest, + AskResponse, + ConversationDeleteResponse, + ConversationDetailResponse, + ConversationRenameRequest, + ConversationSummaryResponse, + HealthResponse, + JudgeToolExecuteRequest, + JudgeToolExecuteResponse, + MetaResponse, + TacticianAskResponse, +) +from magicai.conversation.manager import ConversationManager +from magicai.tactician.core import Tactician, replace_boundary_answer +from magicai.judge_tools import ( + JudgeToolGateway, + JudgeToolRequest, + get_capability_registry_payload, +) +from magicai.judge_result import ( + JudgeConfidence, + JudgeOrigin, + JudgeStatus, +) +from magicai.versioning import ( + API_CONTRACT_VERSION, + JUDGE_RESULT_SCHEMA_VERSION, + JUDGE_TOOL_RESULT_SCHEMA_VERSION, + get_project_version, + get_package_version, + TACTICIAN_RESULT_SCHEMA_VERSION, + RELEASE_CHANNEL, + RELEASE_CODENAME, + RELEASE_TAG, + NEXT_BETA_VERSION, + NEXT_BETA_CODENAME, + V1_CODENAME, +) + + +router = APIRouter() + +assistant = MagicAI() +judge_tool_gateway = JudgeToolGateway() +tactician = Tactician(judge=assistant, tool_gateway=judge_tool_gateway) +conversation_manager = ConversationManager() + + +@router.get("/") +def root(): + return { + "status": "ok", + "project": "MagicAI", + "project_version": get_project_version(), + "package_version": get_package_version(), + "release_channel": RELEASE_CHANNEL, + "release_codename": RELEASE_CODENAME, + "release_tag": RELEASE_TAG, + "api_contract_version": API_CONTRACT_VERSION, + "judge_result_schema_version": JUDGE_RESULT_SCHEMA_VERSION, + "ui": "/ui", + } + + +@router.get("/meta", response_model=MetaResponse) +def meta(): + return { + "project": "MagicAI", + "project_version": get_project_version(), + "package_version": get_package_version(), + "release_channel": RELEASE_CHANNEL, + "release_codename": RELEASE_CODENAME, + "release_tag": RELEASE_TAG, + "api_contract_version": API_CONTRACT_VERSION, + "judge_result_schema_version": JUDGE_RESULT_SCHEMA_VERSION, + "authority": "judge", + "judge_statuses": [item.value for item in JudgeStatus], + "judge_origins": [item.value for item in JudgeOrigin], + "confidence_levels": [item.value for item in JudgeConfidence], + "profiles": ["judge", "tactician"], + "tactician_result_schema_version": TACTICIAN_RESULT_SCHEMA_VERSION, + "next_beta_version": NEXT_BETA_VERSION, + "next_beta_codename": NEXT_BETA_CODENAME, + "v1_codename": V1_CODENAME, + "judge_capabilities": get_capability_registry_payload(), + "judge_tool_result_schema_version": JUDGE_TOOL_RESULT_SCHEMA_VERSION, + "judge_tool_gateway": { + "executable": True, + "read_only": True, + "endpoint": "/judge/tools/execute", + "cache": judge_tool_gateway.cache_info(), + }, + } + + +@router.get("/health", response_model=HealthResponse) +def health(): + return build_health_payload() + + +@router.post( + "/judge/tools/execute", + response_model=JudgeToolExecuteResponse, +) +def execute_judge_tool(request: JudgeToolExecuteRequest): + conversation = None + if request.session_id: + record = conversation_manager.load(request.session_id) + if not record: + raise _conversation_not_found() + conversation = record.conversation + + result = judge_tool_gateway.execute( + JudgeToolRequest( + tool=request.tool, + arguments=dict(request.arguments), + purpose=request.purpose, + request_id=request.request_id, + result_limit=request.result_limit, + ), + conversation=conversation, + ) + return JudgeToolExecuteResponse(**result.to_dict()) + + +@router.post("/ask", response_model=AskResponse) +def ask(request: AskRequest): + session_id, conversation = conversation_manager.get_or_create( + request.session_id + ) + + prior_cards = list(conversation.active_cards) + result = assistant.ask_result( + conversation, + request.question, + ) + + if request.auto_handoff and result.status is JudgeStatus.STRATEGY_REQUIRED: + result = tactician.from_judge_result( + question=request.question, + judge_result=result, + prior_cards=prior_cards, + conversation=conversation, + ) + replace_boundary_answer(conversation, result) + + payload = result.to_dict() + try: + conversation_manager.save( + session_id, + conversation, + last_result={ + "session_id": session_id, + **payload, + }, + ) + except (OSError, sqlite3.Error): + payload["warnings"] = [ + *payload.get("warnings", []), + "La respuesta es válida, pero no se pudo guardar esta conversación en el historial local.", + ] + + return AskResponse( + session_id=session_id, + **payload, + ) + + + + +@router.post("/tactician/ask", response_model=TacticianAskResponse) +def tactician_ask(request: AskRequest): + session_id, conversation = conversation_manager.get_or_create( + request.session_id + ) + + result = tactician.ask_result( + conversation, + request.question, + ) + payload = result.to_dict() + + try: + conversation_manager.save( + session_id, + conversation, + last_result={ + "session_id": session_id, + **payload, + }, + ) + except (OSError, sqlite3.Error): + payload["warnings"] = [ + *payload.get("warnings", []), + "La respuesta es válida, pero no se pudo guardar esta conversación en el historial local.", + ] + + return TacticianAskResponse( + session_id=session_id, + **payload, + ) + + +@router.get( + "/conversations", + response_model=list[ConversationSummaryResponse], +) +def list_conversations( + limit: int = Query(default=50, ge=1, le=200), +): + return [ + _summary_response(summary) + for summary in conversation_manager.list(limit=limit) + ] + + +@router.get( + "/conversations/{session_id}", + response_model=ConversationDetailResponse, +) +def get_conversation(session_id: str): + record = conversation_manager.load(session_id) + if not record: + raise _conversation_not_found() + + summary = record.summary + return ConversationDetailResponse( + session_id=summary.session_id, + title=summary.title, + created_at=summary.created_at, + updated_at=summary.updated_at, + message_count=summary.message_count, + messages=[ + {"role": message.role, "content": message.content} + for message in record.conversation.history + ], + last_result=record.last_result, + ) + + +@router.patch( + "/conversations/{session_id}", + response_model=ConversationSummaryResponse, +) +def rename_conversation( + session_id: str, + request: ConversationRenameRequest, +): + summary = conversation_manager.rename(session_id, request.title) + if not summary: + raise _conversation_not_found() + return _summary_response(summary) + + +@router.delete( + "/conversations/{session_id}", + response_model=ConversationDeleteResponse, +) +def delete_conversation(session_id: str): + deleted = conversation_manager.delete(session_id) + if not deleted: + raise _conversation_not_found() + return ConversationDeleteResponse( + session_id=session_id, + deleted=True, + ) + + +def _summary_response(summary) -> ConversationSummaryResponse: + return ConversationSummaryResponse( + session_id=summary.session_id, + title=summary.title, + created_at=summary.created_at, + updated_at=summary.updated_at, + message_count=summary.message_count, + ) + + +def _conversation_not_found() -> HTTPException: + return HTTPException( + status_code=404, + detail={ + "code": "conversation_not_found", + "message": "The requested local conversation does not exist.", + "retryable": False, + }, + ) diff --git a/magicai/api/schemas.py b/magicai/api/schemas.py index c331a3f..7b16d15 100644 --- a/magicai/api/schemas.py +++ b/magicai/api/schemas.py @@ -1,164 +1,197 @@ -from typing import Any - -from pydantic import BaseModel, Field, field_validator - - -class AskRequest(BaseModel): - question: str = Field(min_length=1, max_length=10000) - session_id: str | None = Field(default=None, max_length=128) - auto_handoff: bool = True - - @field_validator("question") - @classmethod - def normalize_question(cls, value: str) -> str: - normalized = value.strip() - if not normalized: - raise ValueError("question must not be empty") - return normalized - - -class CardEvidenceResponse(BaseModel): - name: str - mana_cost: str | None = None - type_line: str | None = None - oracle_text: str | None = None - scryfall_uri: str | None = None - - -class RuleEvidenceResponse(BaseModel): - number: str | None = None - title: str | None = None - - -class RulingEvidenceResponse(BaseModel): - card_name: str | None = None - oracle_id: str | None = None - source: str | None = None - published_at: str | None = None - comment: str | None = None - - -class AskResponse(BaseModel): - schema_version: str - answer: str - session_id: str - question: str - status: str - origin: str - confidence: str - authority: str = "judge" - intent: str = "" - cards: list[CardEvidenceResponse] = Field(default_factory=list) - rules: list[RuleEvidenceResponse] = Field(default_factory=list) - rulings: list[RulingEvidenceResponse] = Field(default_factory=list) - retrieval_queries: list[str] = Field(default_factory=list) - assumptions: list[str] = Field(default_factory=list) - warnings: list[str] = Field(default_factory=list) - source_versions: dict[str, str] = Field(default_factory=dict) - source_health: dict[str, Any] = Field(default_factory=dict) - validation_attempts: int = 0 - reviewed_by: list[str] = Field(default_factory=list) - review_challenges: list[dict[str, Any]] = Field(default_factory=list) - authority_trace: list[str] = Field(default_factory=list) - strategy_intent: str = "" - synergies: list[str] = Field(default_factory=list) - risks: list[str] = Field(default_factory=list) - combo_classification: str = "" - combo_steps: list[str] = Field(default_factory=list) - outcomes: list[str] = Field(default_factory=list) - inherited_cards: list[str] = Field(default_factory=list) - judge_queries: list[dict[str, Any]] = Field(default_factory=list) - judge_result: dict[str, Any] = Field(default_factory=dict) - - -class TacticianAskResponse(AskResponse): - authority: str = "tactician" - - -class MetaResponse(BaseModel): - project: str - project_version: str - package_version: str - release_channel: str - release_codename: str - release_tag: str - api_contract_version: str - judge_result_schema_version: str - authority: str - judge_statuses: list[str] - judge_origins: list[str] - confidence_levels: list[str] - profiles: list[str] = Field(default_factory=list) - tactician_result_schema_version: str = "" - next_beta_version: str = "" - next_beta_codename: str = "" - v1_codename: str = "" - judge_capabilities: list[dict[str, Any]] = Field(default_factory=list) - - -class HealthResponse(BaseModel): - status: str - ready: bool - full_service: bool - project_version: str - package_version: str - release_channel: str - release_codename: str - release_tag: str - api_contract_version: str - judge_result_schema_version: str - sources: dict[str, Any] - services: dict[str, Any] - - -class ErrorDetailResponse(BaseModel): - location: list[str] = Field(default_factory=list) - message: str = "" - type: str = "" - - -class ErrorBodyResponse(BaseModel): - code: str - message: str - retryable: bool - details: list[ErrorDetailResponse] = Field(default_factory=list) - - -class ErrorResponse(BaseModel): - schema_version: str - error: ErrorBodyResponse - - -class ConversationMessageResponse(BaseModel): - role: str - content: str - - -class ConversationSummaryResponse(BaseModel): - session_id: str - title: str - created_at: str - updated_at: str - message_count: int - - -class ConversationDetailResponse(ConversationSummaryResponse): - messages: list[ConversationMessageResponse] = Field(default_factory=list) - last_result: dict[str, Any] | None = None - - -class ConversationRenameRequest(BaseModel): - title: str = Field(min_length=1, max_length=120) - - @field_validator("title") - @classmethod - def normalize_title(cls, value: str) -> str: - normalized = " ".join(value.strip().split()) - if not normalized: - raise ValueError("title must not be empty") - return normalized - - -class ConversationDeleteResponse(BaseModel): - session_id: str - deleted: bool +from typing import Any + +from pydantic import BaseModel, Field, field_validator + + +class AskRequest(BaseModel): + question: str = Field(min_length=1, max_length=10000) + session_id: str | None = Field(default=None, max_length=128) + auto_handoff: bool = True + + @field_validator("question") + @classmethod + def normalize_question(cls, value: str) -> str: + normalized = value.strip() + if not normalized: + raise ValueError("question must not be empty") + return normalized + + +class CardEvidenceResponse(BaseModel): + name: str + mana_cost: str | None = None + type_line: str | None = None + oracle_text: str | None = None + scryfall_uri: str | None = None + + +class RuleEvidenceResponse(BaseModel): + number: str | None = None + title: str | None = None + + +class RulingEvidenceResponse(BaseModel): + card_name: str | None = None + oracle_id: str | None = None + source: str | None = None + published_at: str | None = None + comment: str | None = None + + +class AskResponse(BaseModel): + schema_version: str + answer: str + session_id: str + question: str + status: str + origin: str + confidence: str + authority: str = "judge" + intent: str = "" + cards: list[CardEvidenceResponse] = Field(default_factory=list) + rules: list[RuleEvidenceResponse] = Field(default_factory=list) + rulings: list[RulingEvidenceResponse] = Field(default_factory=list) + retrieval_queries: list[str] = Field(default_factory=list) + assumptions: list[str] = Field(default_factory=list) + warnings: list[str] = Field(default_factory=list) + source_versions: dict[str, str] = Field(default_factory=dict) + source_health: dict[str, Any] = Field(default_factory=dict) + validation_attempts: int = 0 + reviewed_by: list[str] = Field(default_factory=list) + review_challenges: list[dict[str, Any]] = Field(default_factory=list) + authority_trace: list[str] = Field(default_factory=list) + strategy_intent: str = "" + synergies: list[str] = Field(default_factory=list) + risks: list[str] = Field(default_factory=list) + combo_classification: str = "" + combo_steps: list[str] = Field(default_factory=list) + outcomes: list[str] = Field(default_factory=list) + inherited_cards: list[str] = Field(default_factory=list) + judge_queries: list[dict[str, Any]] = Field(default_factory=list) + judge_tool_calls: list[dict[str, Any]] = Field(default_factory=list) + tactician_synthesized: bool = False + judge_result: dict[str, Any] = Field(default_factory=dict) + + +class TacticianAskResponse(AskResponse): + authority: str = "tactician" + + +class MetaResponse(BaseModel): + project: str + project_version: str + package_version: str + release_channel: str + release_codename: str + release_tag: str + api_contract_version: str + judge_result_schema_version: str + authority: str + judge_statuses: list[str] + judge_origins: list[str] + confidence_levels: list[str] + profiles: list[str] = Field(default_factory=list) + tactician_result_schema_version: str = "" + next_beta_version: str = "" + next_beta_codename: str = "" + v1_codename: str = "" + judge_capabilities: list[dict[str, Any]] = Field(default_factory=list) + judge_tool_result_schema_version: str = "" + judge_tool_gateway: dict[str, Any] = Field(default_factory=dict) + + +class JudgeToolExecuteRequest(BaseModel): + tool: str = Field(min_length=1, max_length=80) + arguments: dict[str, Any] = Field(default_factory=dict) + purpose: str = Field(default="", max_length=240) + request_id: str = Field(default="", max_length=128) + result_limit: int = Field(default=8, ge=1, le=20) + session_id: str | None = Field(default=None, max_length=128) + + +class JudgeToolExecuteResponse(BaseModel): + schema_version: str + tool: str + status: str + authority: str + provider: str + purpose: str = "" + request_id: str = "" + arguments: dict[str, Any] = Field(default_factory=dict) + evidence: list[dict[str, Any]] = Field(default_factory=list) + source_versions: dict[str, str] = Field(default_factory=dict) + warnings: list[str] = Field(default_factory=list) + metadata: dict[str, Any] = Field(default_factory=dict) + error_code: str = "" + error_message: str = "" + elapsed_ms: float = 0.0 + cache_hit: bool = False + budget: dict[str, Any] = Field(default_factory=dict) + + +class HealthResponse(BaseModel): + status: str + ready: bool + full_service: bool + project_version: str + package_version: str + release_channel: str + release_codename: str + release_tag: str + api_contract_version: str + judge_result_schema_version: str + sources: dict[str, Any] + services: dict[str, Any] + + +class ErrorDetailResponse(BaseModel): + location: list[str] = Field(default_factory=list) + message: str = "" + type: str = "" + + +class ErrorBodyResponse(BaseModel): + code: str + message: str + retryable: bool + details: list[ErrorDetailResponse] = Field(default_factory=list) + + +class ErrorResponse(BaseModel): + schema_version: str + error: ErrorBodyResponse + + +class ConversationMessageResponse(BaseModel): + role: str + content: str + + +class ConversationSummaryResponse(BaseModel): + session_id: str + title: str + created_at: str + updated_at: str + message_count: int + + +class ConversationDetailResponse(ConversationSummaryResponse): + messages: list[ConversationMessageResponse] = Field(default_factory=list) + last_result: dict[str, Any] | None = None + + +class ConversationRenameRequest(BaseModel): + title: str = Field(min_length=1, max_length=120) + + @field_validator("title") + @classmethod + def normalize_title(cls, value: str) -> str: + normalized = " ".join(value.strip().split()) + if not normalized: + raise ValueError("title must not be empty") + return normalized + + +class ConversationDeleteResponse(BaseModel): + session_id: str + deleted: bool diff --git a/magicai/judge_tools/__init__.py b/magicai/judge_tools/__init__.py index b21af88..abf95ce 100644 --- a/magicai/judge_tools/__init__.py +++ b/magicai/judge_tools/__init__.py @@ -1,6 +1,15 @@ +from magicai.judge_tools.budget import JudgeToolBudget +from magicai.judge_tools.gateway import JudgeToolGateway +from magicai.judge_tools.models import ( + JudgeToolPayload, + JudgeToolRequest, + JudgeToolResult, + JudgeToolStatus, +) from magicai.judge_tools.registry import ( CapabilityStatus, JudgeCapability, + get_capability, get_capability_registry, get_capability_registry_payload, ) @@ -8,6 +17,13 @@ __all__ = [ "CapabilityStatus", "JudgeCapability", + "JudgeToolBudget", + "JudgeToolGateway", + "JudgeToolPayload", + "JudgeToolRequest", + "JudgeToolResult", + "JudgeToolStatus", + "get_capability", "get_capability_registry", "get_capability_registry_payload", ] diff --git a/magicai/judge_tools/budget.py b/magicai/judge_tools/budget.py new file mode 100644 index 0000000..55b646c --- /dev/null +++ b/magicai/judge_tools/budget.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from collections import Counter +from dataclasses import dataclass, field +import json +import time +from typing import Any + +from magicai.judge_tools.models import JudgeToolRequest + + +@dataclass(slots=True) +class JudgeToolBudget: + """Bound one strategic investigation without limiting normal Judge use.""" + + max_calls: int = 8 + max_calls_per_tool: int = 4 + max_repeated_request: int = 1 + max_elapsed_seconds: float = 30.0 + started_at: float = field(default_factory=time.perf_counter) + calls: int = 0 + per_tool: Counter[str] = field(default_factory=Counter) + request_signatures: Counter[str] = field(default_factory=Counter) + + def consume(self, request: JudgeToolRequest) -> tuple[bool, str]: + if self.calls >= self.max_calls: + return False, "maximum_tool_calls_reached" + if self.per_tool[request.tool] >= self.max_calls_per_tool: + return False, "maximum_calls_for_tool_reached" + if self.elapsed_seconds >= self.max_elapsed_seconds: + return False, "maximum_investigation_time_reached" + + signature = _request_signature(request) + if self.request_signatures[signature] >= self.max_repeated_request: + return False, "repeated_tool_request_blocked" + + self.calls += 1 + self.per_tool[request.tool] += 1 + self.request_signatures[signature] += 1 + return True, "" + + @property + def elapsed_seconds(self) -> float: + return max(0.0, time.perf_counter() - self.started_at) + + def snapshot(self) -> dict[str, Any]: + return { + "calls": self.calls, + "max_calls": self.max_calls, + "per_tool": dict(self.per_tool), + "max_calls_per_tool": self.max_calls_per_tool, + "elapsed_seconds": round(self.elapsed_seconds, 3), + "max_elapsed_seconds": self.max_elapsed_seconds, + } + + +def _request_signature(request: JudgeToolRequest) -> str: + return json.dumps( + { + "tool": request.tool, + "arguments": request.arguments, + }, + sort_keys=True, + ensure_ascii=True, + default=str, + separators=(",", ":"), + ) diff --git a/magicai/judge_tools/gateway.py b/magicai/judge_tools/gateway.py new file mode 100644 index 0000000..7fd9886 --- /dev/null +++ b/magicai/judge_tools/gateway.py @@ -0,0 +1,285 @@ +from __future__ import annotations + +from collections import OrderedDict +from copy import deepcopy +import json +import time +from typing import Any, Callable + +from magicai.judge_tools.budget import JudgeToolBudget +from magicai.judge_tools.models import ( + JudgeToolPayload, + JudgeToolRequest, + JudgeToolResult, + JudgeToolStatus, +) +from magicai.judge_tools.registry import ( + CapabilityStatus, + JudgeCapability, + get_capability, + get_capability_registry, +) +from magicai.judge_tools.tools import ( + ConversationContextTool, + LegalityCheckTool, + OracleLookupTool, + OracleSearchTool, + RulesLookupTool, + RulesSearchTool, + RulingsLookupTool, +) +from magicai.sources.versions import get_source_versions + + +ToolHandler = Callable[..., JudgeToolPayload] + + +class JudgeToolGateway: + """Execute read-only source tools while preserving Judge authority.""" + + def __init__( + self, + *, + handlers: dict[str, ToolHandler] | None = None, + cache_size: int = 256, + ): + self.handlers = handlers or _default_handlers() + self.cache_size = max(0, int(cache_size)) + self._cache: OrderedDict[str, JudgeToolResult] = OrderedDict() + + def capabilities(self) -> tuple[JudgeCapability, ...]: + return get_capability_registry() + + def execute( + self, + request: JudgeToolRequest, + *, + conversation=None, + budget: JudgeToolBudget | None = None, + ) -> JudgeToolResult: + started = time.perf_counter() + capability = get_capability(request.tool) + if capability is None: + return self._error_result( + request, + status=JudgeToolStatus.INVALID_REQUEST, + error_code="unknown_tool", + message=f"Unknown Judge tool: {request.tool}", + elapsed_ms=_elapsed_ms(started), + budget=budget, + ) + + if capability.status not in {CapabilityStatus.AVAILABLE, CapabilityStatus.PARTIAL}: + return self._error_result( + request, + capability=capability, + status=JudgeToolStatus.UNAVAILABLE, + error_code="capability_not_available", + message=( + f"{request.tool} is {capability.status.value}; provider " + f"{capability.provider} is not executable in this build." + ), + elapsed_ms=_elapsed_ms(started), + budget=budget, + ) + + handler = self.handlers.get(request.tool) + if handler is None or not capability.executable: + return self._error_result( + request, + capability=capability, + status=JudgeToolStatus.UNAVAILABLE, + error_code="tool_handler_missing", + message=f"No executable handler is registered for {request.tool}.", + elapsed_ms=_elapsed_ms(started), + budget=budget, + ) + + if request.result_limit <= 0: + return self._error_result( + request, + capability=capability, + status=JudgeToolStatus.INVALID_REQUEST, + error_code="invalid_result_limit", + message="result_limit must be greater than zero.", + elapsed_ms=_elapsed_ms(started), + budget=budget, + ) + + if budget is not None: + accepted, reason = budget.consume(request) + if not accepted: + return self._error_result( + request, + capability=capability, + status=JudgeToolStatus.BUDGET_EXCEEDED, + error_code=reason, + message="The bounded Judge-tool investigation budget rejected this request.", + elapsed_ms=_elapsed_ms(started), + budget=budget, + ) + + result_limit = min(int(request.result_limit), capability.max_results) + source_versions = get_source_versions() + cache_key = self._cache_key(request, source_versions) + if request.tool != "conversation_context": + cached = self._cache_get(cache_key) + if cached is not None: + cached.elapsed_ms = _elapsed_ms(started) + cached.cache_hit = True + cached.budget = budget.snapshot() if budget else {} + return cached + + try: + payload = handler( + dict(request.arguments), + conversation=conversation, + result_limit=result_limit, + ) + except (TypeError, ValueError) as error: + return self._error_result( + request, + capability=capability, + status=JudgeToolStatus.INVALID_REQUEST, + error_code="invalid_arguments", + message=str(error), + elapsed_ms=_elapsed_ms(started), + budget=budget, + ) + except (FileNotFoundError, OSError) as error: + return self._error_result( + request, + capability=capability, + status=JudgeToolStatus.UNAVAILABLE, + error_code="local_source_unavailable", + message=str(error), + elapsed_ms=_elapsed_ms(started), + budget=budget, + ) + except Exception as error: # pragma: no cover - production safety boundary + return self._error_result( + request, + capability=capability, + status=JudgeToolStatus.ERROR, + error_code="tool_execution_failed", + message=f"{type(error).__name__}: {error}", + elapsed_ms=_elapsed_ms(started), + budget=budget, + ) + + result = JudgeToolResult( + tool=request.tool, + status=payload.status, + authority=capability.authority, + provider=capability.provider, + purpose=request.purpose, + request_id=request.request_id, + arguments=dict(request.arguments), + evidence=payload.evidence[:result_limit], + source_versions=source_versions, + warnings=list(payload.warnings), + metadata={ + **payload.metadata, + "result_limit": result_limit, + "read_only": capability.read_only, + }, + elapsed_ms=_elapsed_ms(started), + budget=budget.snapshot() if budget else {}, + ) + if request.tool != "conversation_context" and result.successful: + self._cache_put(cache_key, result) + return result + + def execute_many( + self, + requests: list[JudgeToolRequest], + *, + conversation=None, + budget: JudgeToolBudget | None = None, + ) -> list[JudgeToolResult]: + active_budget = budget or JudgeToolBudget() + return [ + self.execute(request, conversation=conversation, budget=active_budget) + for request in requests + ] + + def clear_cache(self) -> None: + self._cache.clear() + + def cache_info(self) -> dict[str, int]: + return {"size": len(self._cache), "max_size": self.cache_size} + + def _cache_key( + self, + request: JudgeToolRequest, + source_versions: dict[str, str], + ) -> str: + return json.dumps( + { + "tool": request.tool, + "arguments": request.arguments, + "result_limit": request.result_limit, + "source_versions": source_versions, + }, + sort_keys=True, + ensure_ascii=True, + default=str, + separators=(",", ":"), + ) + + def _cache_get(self, key: str) -> JudgeToolResult | None: + if self.cache_size <= 0 or key not in self._cache: + return None + result = self._cache.pop(key) + self._cache[key] = result + return deepcopy(result) + + def _cache_put(self, key: str, result: JudgeToolResult) -> None: + if self.cache_size <= 0: + return + self._cache.pop(key, None) + self._cache[key] = deepcopy(result) + while len(self._cache) > self.cache_size: + self._cache.popitem(last=False) + + def _error_result( + self, + request: JudgeToolRequest, + *, + status: JudgeToolStatus, + error_code: str, + message: str, + elapsed_ms: float, + capability: JudgeCapability | None = None, + budget: JudgeToolBudget | None = None, + ) -> JudgeToolResult: + return JudgeToolResult( + tool=request.tool, + status=status, + authority=capability.authority if capability else "judge_gateway", + provider=capability.provider if capability else "judge_tool_registry", + purpose=request.purpose, + request_id=request.request_id, + arguments=dict(request.arguments), + source_versions=get_source_versions(), + error_code=error_code, + error_message=message, + elapsed_ms=elapsed_ms, + budget=budget.snapshot() if budget else {}, + ) + + +def _default_handlers() -> dict[str, ToolHandler]: + return { + "oracle_lookup": OracleLookupTool(), + "oracle_search": OracleSearchTool(), + "rules_lookup": RulesLookupTool(), + "rules_search": RulesSearchTool(), + "rulings_lookup": RulingsLookupTool(), + "conversation_context": ConversationContextTool(), + "legality_check": LegalityCheckTool(), + } + + +def _elapsed_ms(started: float) -> float: + return (time.perf_counter() - started) * 1000.0 diff --git a/magicai/judge_tools/models.py b/magicai/judge_tools/models.py new file mode 100644 index 0000000..e7ce74b --- /dev/null +++ b/magicai/judge_tools/models.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + +from magicai.versioning import JUDGE_TOOL_RESULT_SCHEMA_VERSION + + +class JudgeToolStatus(str, Enum): + SUCCESS = "success" + NOT_FOUND = "not_found" + INVALID_REQUEST = "invalid_request" + UNAVAILABLE = "unavailable" + BUDGET_EXCEEDED = "budget_exceeded" + ERROR = "error" + + +@dataclass(frozen=True, slots=True) +class JudgeToolRequest: + tool: str + arguments: dict[str, Any] = field(default_factory=dict) + purpose: str = "" + request_id: str = "" + result_limit: int = 8 + + def to_dict(self) -> dict[str, Any]: + return { + "tool": self.tool, + "arguments": dict(self.arguments), + "purpose": self.purpose, + "request_id": self.request_id, + "result_limit": self.result_limit, + } + + +@dataclass(slots=True) +class JudgeToolPayload: + evidence: list[dict[str, Any]] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + status: JudgeToolStatus = JudgeToolStatus.SUCCESS + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass(slots=True) +class JudgeToolResult: + tool: str + status: JudgeToolStatus + authority: str + provider: str + purpose: str = "" + request_id: str = "" + arguments: dict[str, Any] = field(default_factory=dict) + evidence: list[dict[str, Any]] = field(default_factory=list) + source_versions: dict[str, str] = field(default_factory=dict) + warnings: list[str] = field(default_factory=list) + metadata: dict[str, Any] = field(default_factory=dict) + error_code: str = "" + error_message: str = "" + elapsed_ms: float = 0.0 + cache_hit: bool = False + budget: dict[str, Any] = field(default_factory=dict) + + @property + def successful(self) -> bool: + return self.status in {JudgeToolStatus.SUCCESS, JudgeToolStatus.NOT_FOUND} + + def to_dict(self) -> dict[str, Any]: + return { + "schema_version": JUDGE_TOOL_RESULT_SCHEMA_VERSION, + "tool": self.tool, + "status": self.status.value, + "authority": self.authority, + "provider": self.provider, + "purpose": self.purpose, + "request_id": self.request_id, + "arguments": dict(self.arguments), + "evidence": list(self.evidence), + "source_versions": dict(self.source_versions), + "warnings": list(self.warnings), + "metadata": dict(self.metadata), + "error_code": self.error_code, + "error_message": self.error_message, + "elapsed_ms": round(float(self.elapsed_ms), 3), + "cache_hit": bool(self.cache_hit), + "budget": dict(self.budget), + } diff --git a/magicai/judge_tools/registry.py b/magicai/judge_tools/registry.py index 4305b70..364ce87 100644 --- a/magicai/judge_tools/registry.py +++ b/magicai/judge_tools/registry.py @@ -19,6 +19,10 @@ class JudgeCapability: authority: str provider: str description: str + executable: bool = False + read_only: bool = True + max_results: int = 8 + arguments: tuple[str, ...] = () def to_dict(self) -> dict[str, Any]: return { @@ -27,6 +31,10 @@ def to_dict(self) -> dict[str, Any]: "authority": self.authority, "provider": self.provider, "description": self.description, + "executable": self.executable, + "read_only": self.read_only, + "max_results": self.max_results, + "arguments": list(self.arguments), } @@ -37,13 +45,39 @@ def to_dict(self) -> dict[str, Any]: authority="official_card_data", provider="local_scryfall_oracle", description="Resolve supported paper cards and current Oracle text from the local Scryfall snapshot.", + executable=True, + max_results=20, + arguments=("card_names",), + ), + JudgeCapability( + name="oracle_search", + status=CapabilityStatus.AVAILABLE, + authority="official_card_data", + provider="local_scryfall_oracle", + description="Search supported paper card names in the local Scryfall Oracle snapshot.", + executable=True, + max_results=20, + arguments=("query", "limit"), + ), + JudgeCapability( + name="rules_lookup", + status=CapabilityStatus.AVAILABLE, + authority="official_rules", + provider="local_comprehensive_rules", + description="Resolve exact numbered rules or named rule sections from the local Comprehensive Rules snapshot.", + executable=True, + max_results=20, + arguments=("identifiers",), ), JudgeCapability( name="rules_search", status=CapabilityStatus.AVAILABLE, authority="official_rules", provider="local_comprehensive_rules", - description="Retrieve numbered and conceptual rules from the local Comprehensive Rules snapshot.", + description="Search conceptual rules in the local Comprehensive Rules snapshot.", + executable=True, + max_results=12, + arguments=("query", "limit"), ), JudgeCapability( name="rulings_lookup", @@ -51,20 +85,29 @@ def to_dict(self) -> dict[str, Any]: authority="official_rulings", provider="local_scryfall_rulings", description="Retrieve current local Scryfall rulings and preserve their provenance.", + executable=True, + max_results=20, + arguments=("card_names", "oracle_ids"), ), JudgeCapability( name="conversation_context", status=CapabilityStatus.AVAILABLE, authority="session_state", provider="local_conversation_repository", - description="Reuse Judge-validated cards and rule topics across follow-up turns.", + description="Reuse Judge-validated cards, rule topics, and bounded recent turns across follow-up questions.", + executable=True, + max_results=1, + arguments=("include_history", "history_limit"), ), JudgeCapability( name="legality_check", - status=CapabilityStatus.PARTIAL, + status=CapabilityStatus.AVAILABLE, authority="official_card_data", provider="local_scryfall_oracle", - description="Legality exists in the Oracle snapshot but is not yet exposed through the full Tactician evidence contract.", + description="Return local Scryfall legality and color-identity data for explicitly named cards.", + executable=True, + max_results=20, + arguments=("card_names", "formats"), ), JudgeCapability( name="spellbook_search", @@ -72,6 +115,7 @@ def to_dict(self) -> dict[str, Any]: authority="community_combo_candidate", provider="commander_spellbook", description="Find community combo candidates, then revalidate every step through Oracle and the Comprehensive Rules.", + arguments=("card_names", "deck_cards", "max_missing_cards"), ), JudgeCapability( name="strategic_statistics", @@ -79,6 +123,7 @@ def to_dict(self) -> dict[str, Any]: authority="statistical", provider="authorized_edhrec_or_import", description="Use authorized statistics or an explicitly imported snapshot; never treat popularity as rules authority.", + arguments=("commander", "card_names", "theme"), ), JudgeCapability( name="collection_lookup", @@ -86,13 +131,20 @@ def to_dict(self) -> dict[str, Any]: authority="user_data", provider="local_collection", description="Check cards owned by the user without exposing the collection directly to the Tactician.", + arguments=("card_names",), ), ) +_CAPABILITY_INDEX = {capability.name: capability for capability in _CAPABILITIES} + def get_capability_registry() -> tuple[JudgeCapability, ...]: return _CAPABILITIES +def get_capability(name: str) -> JudgeCapability | None: + return _CAPABILITY_INDEX.get((name or "").strip()) + + def get_capability_registry_payload() -> list[dict[str, Any]]: return [capability.to_dict() for capability in _CAPABILITIES] diff --git a/magicai/judge_tools/tools/__init__.py b/magicai/judge_tools/tools/__init__.py new file mode 100644 index 0000000..ab19048 --- /dev/null +++ b/magicai/judge_tools/tools/__init__.py @@ -0,0 +1,15 @@ +from magicai.judge_tools.tools.conversation import ConversationContextTool +from magicai.judge_tools.tools.legality import LegalityCheckTool +from magicai.judge_tools.tools.oracle import OracleLookupTool, OracleSearchTool +from magicai.judge_tools.tools.rules import RulesLookupTool, RulesSearchTool +from magicai.judge_tools.tools.rulings import RulingsLookupTool + +__all__ = [ + "ConversationContextTool", + "LegalityCheckTool", + "OracleLookupTool", + "OracleSearchTool", + "RulesLookupTool", + "RulesSearchTool", + "RulingsLookupTool", +] diff --git a/magicai/judge_tools/tools/common.py b/magicai/judge_tools/tools/common.py new file mode 100644 index 0000000..54fc143 --- /dev/null +++ b/magicai/judge_tools/tools/common.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from dataclasses import fields, is_dataclass +from typing import Any + + +CARD_FIELDS = ( + "oracle_id", + "id", + "name", + "mana_cost", + "cmc", + "type_line", + "oracle_text", + "power", + "toughness", + "loyalty", + "defense", + "colors", + "color_identity", + "keywords", + "legalities", + "rarity", + "released_at", + "scryfall_uri", + "rulings_uri", +) + + +def card_to_evidence(card: Any) -> dict[str, Any]: + if isinstance(card, dict): + source = card + elif is_dataclass(card): + source = {item.name: getattr(card, item.name) for item in fields(card)} + else: + source = { + key: getattr(card, key, None) + for key in CARD_FIELDS + if hasattr(card, key) + } + + data = { + key: source.get(key) + for key in CARD_FIELDS + if source.get(key) is not None + } + return { + "kind": "card", + "identifier": str(data.get("oracle_id") or data.get("name") or ""), + "data": data, + } + + +def rule_to_evidence(rule: dict[str, Any]) -> dict[str, Any]: + data = { + "number": str(rule.get("number", "")), + "title": str(rule.get("title", "")), + "rules": [ + {"number": str(number), "text": str(text)} + for number, text in rule.get("rules", []) + ], + } + return { + "kind": "rule", + "identifier": data["number"] or data["title"], + "data": data, + } + + +def clean_string_list(value: Any, *, field_name: str) -> list[str]: + if isinstance(value, str): + values = [value] + elif isinstance(value, (list, tuple)): + values = list(value) + elif value is None: + values = [] + else: + raise ValueError(f"{field_name} must be a string or a list of strings") + + result: list[str] = [] + seen: set[str] = set() + for item in values: + text = str(item).strip() + key = text.casefold() + if not text or key in seen: + continue + seen.add(key) + result.append(text) + return result diff --git a/magicai/judge_tools/tools/conversation.py b/magicai/judge_tools/tools/conversation.py new file mode 100644 index 0000000..dc8a5f7 --- /dev/null +++ b/magicai/judge_tools/tools/conversation.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from typing import Any + +from magicai.judge_tools.models import JudgeToolPayload, JudgeToolStatus +from magicai.judge_tools.tools.common import card_to_evidence + + +class ConversationContextTool: + def __call__( + self, + arguments: dict[str, Any], + *, + conversation=None, + result_limit: int = 8, + ) -> JudgeToolPayload: + if conversation is None: + return JudgeToolPayload( + status=JudgeToolStatus.NOT_FOUND, + warnings=["No conversation was supplied to conversation_context."], + ) + + include_history = bool(arguments.get("include_history", False)) + history_limit = max(0, min(int(arguments.get("history_limit", 6)), 20)) + active_cards = [card_to_evidence(card)["data"] for card in conversation.active_cards] + data: dict[str, Any] = { + "active_cards": active_cards, + "active_keywords": list(conversation.active_keywords), + "active_rules": list(conversation.active_rules), + "active_rule_queries": list(conversation.active_rule_queries), + "last_intent": conversation.last_intent, + "language": conversation.language, + "mode": conversation.mode, + } + if include_history: + data["recent_history"] = [ + {"role": message.role, "content": message.content} + for message in conversation.history[-history_limit:] + ] + + return JudgeToolPayload( + evidence=[ + { + "kind": "conversation_context", + "identifier": "active_session", + "data": data, + } + ], + metadata={"history_included": include_history}, + ) diff --git a/magicai/judge_tools/tools/legality.py b/magicai/judge_tools/tools/legality.py new file mode 100644 index 0000000..63656ac --- /dev/null +++ b/magicai/judge_tools/tools/legality.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from typing import Any + +from magicai.judge_tools.models import JudgeToolPayload, JudgeToolStatus +from magicai.judge_tools.tools.common import clean_string_list +from magicai.repositories.card_repository import CardRepository + + +class LegalityCheckTool: + def __init__(self, repository: CardRepository | None = None): + self.repository = repository or CardRepository() + + def __call__( + self, + arguments: dict[str, Any], + *, + conversation=None, + result_limit: int = 8, + ) -> JudgeToolPayload: + names = clean_string_list( + arguments.get("card_names", arguments.get("name")), + field_name="card_names", + ) + formats = [item.casefold() for item in clean_string_list( + arguments.get("formats"), + field_name="formats", + )] + if not names: + raise ValueError("legality_check requires card_names") + + evidence: list[dict[str, Any]] = [] + missing: list[str] = [] + for name in names[:result_limit]: + card = self.repository.find_by_name(name) + if card is None: + missing.append(name) + continue + legalities = dict(card.legalities or {}) + selected = ( + {format_name: legalities.get(format_name, "not_legal") for format_name in formats} + if formats + else legalities + ) + evidence.append( + { + "kind": "legality", + "identifier": card.oracle_id or card.name, + "data": { + "card_name": card.name, + "oracle_id": card.oracle_id, + "color_identity": list(card.color_identity or []), + "legalities": selected, + }, + } + ) + + warnings = [] + if missing: + warnings.append("Cards not resolved for legality: " + ", ".join(missing)) + return JudgeToolPayload( + evidence=evidence, + warnings=warnings, + status=(JudgeToolStatus.SUCCESS if evidence else JudgeToolStatus.NOT_FOUND), + metadata={"formats": formats, "resolved_count": len(evidence)}, + ) diff --git a/magicai/judge_tools/tools/oracle.py b/magicai/judge_tools/tools/oracle.py new file mode 100644 index 0000000..9d74076 --- /dev/null +++ b/magicai/judge_tools/tools/oracle.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from magicai.judge_tools.models import JudgeToolPayload, JudgeToolStatus +from magicai.judge_tools.tools.common import card_to_evidence, clean_string_list +from magicai.repositories.card_repository import CardRepository +from magicai.scryfall import search_cards_by_name + + +class OracleLookupTool: + def __init__(self, repository: CardRepository | None = None): + self.repository = repository or CardRepository() + + def __call__( + self, + arguments: dict[str, Any], + *, + conversation=None, + result_limit: int = 8, + ) -> JudgeToolPayload: + names = clean_string_list( + arguments.get("card_names", arguments.get("name")), + field_name="card_names", + ) + if not names: + raise ValueError("oracle_lookup requires card_names") + + evidence: list[dict[str, Any]] = [] + missing: list[str] = [] + for name in names[:result_limit]: + card = self.repository.find_by_name(name) + if card is None: + missing.append(name) + continue + evidence.append(card_to_evidence(card)) + + warnings = [] + if missing: + warnings.append("Cards not resolved exactly: " + ", ".join(missing)) + return JudgeToolPayload( + evidence=evidence, + warnings=warnings, + status=(JudgeToolStatus.SUCCESS if evidence else JudgeToolStatus.NOT_FOUND), + metadata={"requested_names": names, "resolved_count": len(evidence)}, + ) + + +class OracleSearchTool: + def __init__( + self, + search: Callable[[str], list[dict[str, Any]]] | None = None, + ): + self.search = search or search_cards_by_name + + def __call__( + self, + arguments: dict[str, Any], + *, + conversation=None, + result_limit: int = 8, + ) -> JudgeToolPayload: + query = str(arguments.get("query", "")).strip() + if not query: + raise ValueError("oracle_search requires query") + + cards = self.search(query)[:result_limit] + return JudgeToolPayload( + evidence=[card_to_evidence(card) for card in cards], + status=(JudgeToolStatus.SUCCESS if cards else JudgeToolStatus.NOT_FOUND), + metadata={"query": query, "match_count": len(cards)}, + ) diff --git a/magicai/judge_tools/tools/rules.py b/magicai/judge_tools/tools/rules.py new file mode 100644 index 0000000..d3a3d94 --- /dev/null +++ b/magicai/judge_tools/tools/rules.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from typing import Any + +from magicai.judge_tools.models import JudgeToolPayload, JudgeToolStatus +from magicai.judge_tools.tools.common import clean_string_list, rule_to_evidence +from magicai.repositories.rule_repository import RuleRepository + + +class RulesLookupTool: + def __init__(self, repository: RuleRepository | None = None): + self.repository = repository or RuleRepository() + + def __call__( + self, + arguments: dict[str, Any], + *, + conversation=None, + result_limit: int = 8, + ) -> JudgeToolPayload: + identifiers = clean_string_list( + arguments.get("identifiers", arguments.get("identifier")), + field_name="identifiers", + ) + if not identifiers: + raise ValueError("rules_lookup requires identifiers") + + evidence: list[dict[str, Any]] = [] + missing: list[str] = [] + seen: set[str] = set() + for identifier in identifiers[:result_limit]: + rule = self.repository.find_by_keyword(identifier) + if rule is None: + missing.append(identifier) + continue + item = rule_to_evidence(rule) + key = item["identifier"] + if key in seen: + continue + seen.add(key) + evidence.append(item) + + warnings = [] + if missing: + warnings.append("Rules not resolved: " + ", ".join(missing)) + return JudgeToolPayload( + evidence=evidence, + warnings=warnings, + status=(JudgeToolStatus.SUCCESS if evidence else JudgeToolStatus.NOT_FOUND), + metadata={"requested_identifiers": identifiers, "resolved_count": len(evidence)}, + ) + + +class RulesSearchTool: + def __init__(self, repository: RuleRepository | None = None): + self.repository = repository or RuleRepository() + + def __call__( + self, + arguments: dict[str, Any], + *, + conversation=None, + result_limit: int = 8, + ) -> JudgeToolPayload: + query = str(arguments.get("query", "")).strip() + if not query: + raise ValueError("rules_search requires query") + requested_limit = int(arguments.get("limit", result_limit)) + limit = max(1, min(requested_limit, result_limit)) + rules = self.repository.search(query, limit=limit) + return JudgeToolPayload( + evidence=[rule_to_evidence(rule) for rule in rules], + status=(JudgeToolStatus.SUCCESS if rules else JudgeToolStatus.NOT_FOUND), + metadata={"query": query, "match_count": len(rules)}, + ) diff --git a/magicai/judge_tools/tools/rulings.py b/magicai/judge_tools/tools/rulings.py new file mode 100644 index 0000000..06f6142 --- /dev/null +++ b/magicai/judge_tools/tools/rulings.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from typing import Any + +from magicai.judge_tools.models import JudgeToolPayload, JudgeToolStatus +from magicai.judge_tools.tools.common import clean_string_list +from magicai.repositories.card_repository import CardRepository +from magicai.repositories.ruling_repository import RulingRepository + + +class RulingsLookupTool: + def __init__( + self, + card_repository: CardRepository | None = None, + ruling_repository: RulingRepository | None = None, + ): + self.card_repository = card_repository or CardRepository() + self.ruling_repository = ruling_repository or RulingRepository() + + def __call__( + self, + arguments: dict[str, Any], + *, + conversation=None, + result_limit: int = 8, + ) -> JudgeToolPayload: + names = clean_string_list(arguments.get("card_names"), field_name="card_names") + oracle_ids = clean_string_list(arguments.get("oracle_ids"), field_name="oracle_ids") + if not names and not oracle_ids: + raise ValueError("rulings_lookup requires card_names or oracle_ids") + + resolved: list[tuple[str, str]] = [] + missing: list[str] = [] + for name in names: + card = self.card_repository.find_by_name(name) + if card is None: + missing.append(name) + continue + resolved.append((card.name, card.oracle_id)) + resolved.extend(("", oracle_id) for oracle_id in oracle_ids) + + evidence: list[dict[str, Any]] = [] + for card_name, oracle_id in resolved: + remaining = max(0, result_limit - len(evidence)) + if remaining <= 0: + break + for ruling in self.ruling_repository.find_by_oracle_id( + oracle_id, + limit=remaining, + ): + data = dict(ruling) + if card_name: + data["card_name"] = card_name + evidence.append( + { + "kind": "ruling", + "identifier": f"{oracle_id}:{data.get('published_at', '')}", + "data": data, + } + ) + + warnings = [] + if missing: + warnings.append("Cards not resolved for rulings: " + ", ".join(missing)) + return JudgeToolPayload( + evidence=evidence, + warnings=warnings, + status=(JudgeToolStatus.SUCCESS if evidence else JudgeToolStatus.NOT_FOUND), + metadata={"oracle_ids_checked": [oracle_id for _, oracle_id in resolved]}, + ) diff --git a/magicai/tactician/core.py b/magicai/tactician/core.py index 178d102..6e01f25 100644 --- a/magicai/tactician/core.py +++ b/magicai/tactician/core.py @@ -4,6 +4,12 @@ from typing import Any from magicai.assistant import MagicAI +from magicai.judge_tools import ( + JudgeToolBudget, + JudgeToolGateway, + JudgeToolRequest, + JudgeToolStatus, +) from magicai.tactician.intents import ( classify_strategy_intent, looks_like_referential_follow_up, @@ -15,8 +21,13 @@ class Tactician: """Strategic profile that investigates through Judge-owned evidence.""" - def __init__(self, judge: MagicAI | None = None): + def __init__( + self, + judge: MagicAI | None = None, + tool_gateway: JudgeToolGateway | None = None, + ): self.judge = judge or MagicAI() + self.tool_gateway = tool_gateway or JudgeToolGateway() def ask_result(self, conversation, question: str) -> TacticianResult: """Direct Tactician entry point without duplicating conversation turns.""" @@ -29,6 +40,7 @@ def ask_result(self, conversation, question: str) -> TacticianResult: question=question, judge_result=judge_result, prior_cards=prior_conversation.active_cards, + conversation=prior_conversation, ) _copy_state(judge_conversation, conversation) @@ -44,12 +56,13 @@ def from_judge_result( question: str, judge_result, prior_cards: list[Any] | None = None, + conversation=None, ) -> TacticianResult: - """Build a strategic result from an already executed Judge query. + """Build a strategic result from a Judge package. - This is used by the automatic handoff in ``POST /ask``. The Tactician - receives the Judge package and optional active cards from the previous - turn, but it never opens Oracle, rules, or external sources directly. + The Tactician may request bounded, read-only evidence through the + Judge Tool Gateway. It never opens Oracle, rules, rulings, or future + strategic providers directly. """ judge_payload = judge_result.to_dict() @@ -59,8 +72,15 @@ def from_judge_result( previous=prior_cards or [], inherit=looks_like_referential_follow_up(question), ) - judge_payload = {**judge_payload, "cards": merged_cards} + tool_calls: list[dict[str, Any]] = [] + if conversation is not None and merged_cards: + merged_cards, tool_calls = self._refresh_oracle_evidence( + cards=merged_cards, + conversation=conversation, + ) + + judge_payload = {**judge_payload, "cards": merged_cards} judge_status = str(judge_payload.get("status", "")) if judge_status == "strategy_required": analysis = analyze_strategy( @@ -80,6 +100,7 @@ def from_judge_result( combo_classification = analysis.combo_classification combo_steps = analysis.combo_steps outcomes = analysis.outcomes + tactician_synthesized = True else: answer = str(judge_payload.get("answer", "")) synergies = [] @@ -90,6 +111,7 @@ def from_judge_result( status = judge_status or "insufficient_evidence" origin = "tactician_judge_gate" confidence = str(judge_payload.get("confidence", "low")) + tactician_synthesized = False warnings = list(judge_payload.get("warnings", [])) if judge_status not in {"strategy_required", "answered"}: @@ -97,6 +119,40 @@ def from_judge_result( "The Judge could not close the factual package, so the Tactician did not add a strategic recommendation." ) + judge_queries = [ + { + "sequence": 1, + "purpose": "factual_evidence", + "question": question, + "status": judge_status, + "origin": str(judge_payload.get("origin", "")), + } + ] + for sequence, tool_call in enumerate(tool_calls, start=2): + judge_queries.append( + { + "sequence": sequence, + "purpose": tool_call.get("purpose", "judge_tool_evidence"), + "tool": tool_call.get("tool", ""), + "status": tool_call.get("status", ""), + "evidence_count": len(tool_call.get("evidence", [])), + "cache_hit": bool(tool_call.get("cache_hit", False)), + } + ) + + if origin == "tactician_strategy": + authority_trace = ["judge:factual_evidence"] + if tool_calls: + authority_trace.append("judge:tool_gateway") + authority_trace.extend( + [ + "tactician:strategic_interpretation", + "judge:source_gateway", + ] + ) + else: + authority_trace = ["judge:factual_answer", "tactician:relay"] + return TacticianResult( question=question, answer=answer, @@ -116,27 +172,69 @@ def from_judge_result( combo_steps=combo_steps, outcomes=outcomes, inherited_cards=inherited_names, - judge_queries=[ - { - "sequence": 1, - "purpose": "factual_evidence", - "question": question, - "status": judge_status, - "origin": str(judge_payload.get("origin", "")), - } - ], - authority_trace=( - [ - "judge:factual_evidence", - "tactician:strategic_interpretation", - "judge:source_gateway", - ] - if origin == "tactician_strategy" - else ["judge:factual_answer", "tactician:relay"] - ), + judge_queries=judge_queries, + judge_tool_calls=tool_calls, + tactician_synthesized=tactician_synthesized, + authority_trace=authority_trace, judge_result=judge_payload, ) + def execute_judge_tool( + self, + request: JudgeToolRequest, + *, + conversation=None, + budget: JudgeToolBudget | None = None, + ): + """Public strategic-side entry point to the Judge-owned gateway.""" + + return self.tool_gateway.execute( + request, + conversation=conversation, + budget=budget, + ) + + def _refresh_oracle_evidence( + self, + *, + cards: list[dict[str, Any]], + conversation, + ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + names = [str(card.get("name", "")).strip() for card in cards] + names = [name for name in names if name] + if not names: + return cards, [] + + budget = JudgeToolBudget( + max_calls=3, + max_calls_per_tool=2, + max_repeated_request=1, + max_elapsed_seconds=10.0, + ) + result = self.execute_judge_tool( + JudgeToolRequest( + tool="oracle_lookup", + arguments={"card_names": names}, + purpose="refresh_strategic_oracle_evidence", + result_limit=max(1, min(len(names), 20)), + ), + conversation=conversation, + budget=budget, + ) + payload = result.to_dict() + if result.status is not JudgeToolStatus.SUCCESS: + return cards, [payload] + + refreshed = [ + dict(item.get("data", {})) + for item in result.evidence + if item.get("kind") == "card" and item.get("data") + ] + if not refreshed: + return cards, [payload] + + return _merge_refreshed_cards(cards, refreshed), [payload] + def replace_boundary_answer(conversation, result: TacticianResult) -> None: """Replace the Judge boundary message after an automatic handoff.""" @@ -184,11 +282,25 @@ def _card_to_dict(card: Any) -> dict[str, Any]: return { key: getattr(card, key, None) for key in ( + "oracle_id", + "id", "name", "mana_cost", + "cmc", "type_line", "oracle_text", + "power", + "toughness", + "loyalty", + "defense", + "colors", + "color_identity", + "keywords", + "legalities", + "rarity", + "released_at", "scryfall_uri", + "rulings_uri", ) if getattr(card, key, None) is not None } @@ -207,6 +319,22 @@ def _deduplicate_cards(cards: list[dict[str, Any]]) -> list[dict[str, Any]]: return result +def _merge_refreshed_cards( + original: list[dict[str, Any]], + refreshed: list[dict[str, Any]], +) -> list[dict[str, Any]]: + by_name = { + str(card.get("name", "")).casefold(): card + for card in refreshed + if card.get("name") + } + result = [] + for card in original: + replacement = by_name.get(str(card.get("name", "")).casefold()) + result.append(deepcopy(replacement or card)) + return _deduplicate_cards(result) + + def _copy_state(source, target) -> None: target.active_cards = deepcopy(source.active_cards) target.active_keywords = list(source.active_keywords) diff --git a/magicai/tactician/models.py b/magicai/tactician/models.py index b821bc5..32db32d 100644 --- a/magicai/tactician/models.py +++ b/magicai/tactician/models.py @@ -73,6 +73,8 @@ class TacticianResult: outcomes: list[str] = field(default_factory=list) inherited_cards: list[str] = field(default_factory=list) judge_queries: list[dict[str, Any]] = field(default_factory=list) + judge_tool_calls: list[dict[str, Any]] = field(default_factory=list) + tactician_synthesized: bool = False authority_trace: list[str] = field(default_factory=list) judge_result: dict[str, Any] = field(default_factory=dict) @@ -107,6 +109,8 @@ def to_dict(self) -> dict[str, Any]: "outcomes": list(self.outcomes), "inherited_cards": list(self.inherited_cards), "judge_queries": list(self.judge_queries), + "judge_tool_calls": list(self.judge_tool_calls), + "tactician_synthesized": bool(self.tactician_synthesized), "authority_trace": list(self.authority_trace), "judge_result": dict(self.judge_result), } diff --git a/magicai/ui/static/app.js b/magicai/ui/static/app.js index 8856f5a..1296a1c 100644 --- a/magicai/ui/static/app.js +++ b/magicai/ui/static/app.js @@ -850,6 +850,8 @@ function renderTechnicalDetails(result) { ["Cartas heredadas", (result.inherited_cards || []).join(" · ") || "—"], ["Resultados", (result.outcomes || []).join(" · ") || "—"], ["Consultas al Juez", (result.judge_queries || []).map(item => `${item.sequence}: ${item.purpose}`).join(" · ") || "—"], + ["Herramientas del Juez", (result.judge_tool_calls || []).map(item => `${item.tool}: ${item.status}${item.cache_hit ? " (caché)" : ""}`).join(" · ") || "—"], + ["Síntesis del Estratega", result.tactician_synthesized ? "sí" : "no"], ["Intentos de validación", String(result.validation_attempts ?? 0)], ["Revisado por", (result.reviewed_by || []).join(" · ") || "—"], ["Trazado de autoridad", (result.authority_trace || []).join(" → ") || "—"], diff --git a/magicai/versioning.py b/magicai/versioning.py index e07517c..55892ba 100644 --- a/magicai/versioning.py +++ b/magicai/versioning.py @@ -1,40 +1,41 @@ -from __future__ import annotations - -JUDGE_RESULT_SCHEMA_VERSION = "1.0" -API_CONTRACT_VERSION = "1.3" -TACTICIAN_RESULT_SCHEMA_VERSION = "0.2" - -# Packaging metadata must follow PEP 440. Public release names remain SemVer-like. -PACKAGE_FALLBACK_VERSION = "0.1.1b0" -PUBLIC_VERSION = "0.1.1-beta" -RELEASE_TAG = "v0.1.1-beta" -RELEASE_CHANNEL = "beta" -RELEASE_CODENAME = "Force of Will" - -NEXT_BETA_VERSION = "0.2.0-beta" -NEXT_BETA_CODENAME = "Ponder" -V1_CODENAME = "NicolAI Bolas" - - -def get_package_version() -> str: - """Return the canonical PEP 440 package version.""" - - return PACKAGE_FALLBACK_VERSION - - -def get_project_version() -> str: - """Return the public release version used by the API and UI.""" - - return PUBLIC_VERSION - - -def get_release_identity() -> dict[str, str]: - """Return the canonical public release identity.""" - - return { - "version": PUBLIC_VERSION, - "tag": RELEASE_TAG, - "channel": RELEASE_CHANNEL, - "codename": RELEASE_CODENAME, - "package_version": get_package_version(), - } +from __future__ import annotations + +JUDGE_RESULT_SCHEMA_VERSION = "1.0" +JUDGE_TOOL_RESULT_SCHEMA_VERSION = "1.0" +API_CONTRACT_VERSION = "1.4" +TACTICIAN_RESULT_SCHEMA_VERSION = "0.3" + +# Packaging metadata must follow PEP 440. Public release names remain SemVer-like. +PACKAGE_FALLBACK_VERSION = "0.1.1b0" +PUBLIC_VERSION = "0.1.1-beta" +RELEASE_TAG = "v0.1.1-beta" +RELEASE_CHANNEL = "beta" +RELEASE_CODENAME = "Force of Will" + +NEXT_BETA_VERSION = "0.2.0-beta" +NEXT_BETA_CODENAME = "Ponder" +V1_CODENAME = "NicolAI Bolas" + + +def get_package_version() -> str: + """Return the canonical PEP 440 package version.""" + + return PACKAGE_FALLBACK_VERSION + + +def get_project_version() -> str: + """Return the public release version used by the API and UI.""" + + return PUBLIC_VERSION + + +def get_release_identity() -> dict[str, str]: + """Return the canonical public release identity.""" + + return { + "version": PUBLIC_VERSION, + "tag": RELEASE_TAG, + "channel": RELEASE_CHANNEL, + "codename": RELEASE_CODENAME, + "package_version": get_package_version(), + } diff --git a/scripts/ci_check.py b/scripts/ci_check.py index 892b14e..1290d5b 100644 --- a/scripts/ci_check.py +++ b/scripts/ci_check.py @@ -16,6 +16,10 @@ TEST_MODULES = ( "tests.repository.repository_health_test", "tests.repository.release_packaging_test", + "tests.judge_tools.judge_tool_gateway_test", + "tests.judge_tools.local_tools_test", + "tests.api.judge_tool_api_test", + "tests.tactician.tactician_tool_gateway_test", "tests.tactician.tactician_reviewer_test", "tests.tactician.tactician_strategy_test", "tests.tactician.tactician_conversation_handoff_test", diff --git a/tests/api/judge_tool_api_test.py b/tests/api/judge_tool_api_test.py new file mode 100644 index 0000000..8245703 --- /dev/null +++ b/tests/api/judge_tool_api_test.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from magicai.api import routes +from magicai.api.schemas import JudgeToolExecuteRequest +from magicai.judge_tools.models import JudgeToolResult, JudgeToolStatus +from magicai.versioning import JUDGE_TOOL_RESULT_SCHEMA_VERSION + + +class FakeGateway: + def execute(self, request, *, conversation=None, budget=None): + return JudgeToolResult( + tool=request.tool, + status=JudgeToolStatus.SUCCESS, + authority="official_card_data", + provider="local_scryfall_oracle", + purpose=request.purpose, + arguments=dict(request.arguments), + evidence=[{"kind": "card", "identifier": "Young Wolf", "data": {"name": "Young Wolf"}}], + ) + + def cache_info(self): + return {"size": 0, "max_size": 1} + + +def test_meta_exposes_executable_gateway_contract() -> None: + payload = routes.meta() + assert payload["judge_tool_result_schema_version"] == JUDGE_TOOL_RESULT_SCHEMA_VERSION + assert payload["judge_tool_gateway"]["executable"] is True + capabilities = {item["name"]: item for item in payload["judge_capabilities"]} + assert capabilities["oracle_lookup"]["executable"] is True + assert capabilities["legality_check"]["status"] == "available" + assert capabilities["spellbook_search"]["executable"] is False + + +def test_execute_endpoint_returns_structured_tool_result() -> None: + original = routes.judge_tool_gateway + routes.judge_tool_gateway = FakeGateway() + try: + response = routes.execute_judge_tool( + JudgeToolExecuteRequest( + tool="oracle_lookup", + arguments={"card_names": ["Young Wolf"]}, + purpose="api_test", + ) + ) + finally: + routes.judge_tool_gateway = original + + payload = response.model_dump() + assert payload["schema_version"] == JUDGE_TOOL_RESULT_SCHEMA_VERSION + assert payload["authority"] == "official_card_data" + assert payload["evidence"][0]["data"]["name"] == "Young Wolf" + + +def main() -> int: + tests = [ + test_meta_exposes_executable_gateway_contract, + test_execute_endpoint_returns_structured_tool_result, + ] + for test in tests: + test() + print(f"OK: {test.__name__}") + print(f"Judge tool API tests: {len(tests)}/{len(tests)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/judge_tools/__init__.py b/tests/judge_tools/__init__.py new file mode 100644 index 0000000..5e1e6b1 --- /dev/null +++ b/tests/judge_tools/__init__.py @@ -0,0 +1 @@ +"""Judge Tool Gateway tests.""" diff --git a/tests/judge_tools/judge_tool_gateway_test.py b/tests/judge_tools/judge_tool_gateway_test.py new file mode 100644 index 0000000..07f721f --- /dev/null +++ b/tests/judge_tools/judge_tool_gateway_test.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from magicai.judge_tools import ( + JudgeToolBudget, + JudgeToolGateway, + JudgeToolPayload, + JudgeToolRequest, + JudgeToolStatus, +) + + +class CountingHandler: + def __init__(self): + self.calls = 0 + + def __call__(self, arguments, *, conversation=None, result_limit=8): + self.calls += 1 + return JudgeToolPayload( + evidence=[ + { + "kind": "card", + "identifier": "Young Wolf", + "data": {"name": "Young Wolf"}, + } + ], + metadata={"received_limit": result_limit}, + ) + + +def test_gateway_executes_and_caches_read_only_tool() -> None: + handler = CountingHandler() + gateway = JudgeToolGateway(handlers={"oracle_lookup": handler}, cache_size=4) + request = JudgeToolRequest( + tool="oracle_lookup", + arguments={"card_names": ["Young Wolf"]}, + purpose="test", + ) + + first = gateway.execute(request) + second = gateway.execute(request) + + assert first.status is JudgeToolStatus.SUCCESS + assert first.authority == "official_card_data" + assert first.provider == "local_scryfall_oracle" + assert first.cache_hit is False + assert second.cache_hit is True + assert handler.calls == 1 + assert second.evidence[0]["data"]["name"] == "Young Wolf" + + +def test_gateway_rejects_unknown_and_planned_tools() -> None: + gateway = JudgeToolGateway(handlers={}) + unknown = gateway.execute(JudgeToolRequest(tool="not_a_tool")) + planned = gateway.execute(JudgeToolRequest(tool="spellbook_search")) + + assert unknown.status is JudgeToolStatus.INVALID_REQUEST + assert unknown.error_code == "unknown_tool" + assert planned.status is JudgeToolStatus.UNAVAILABLE + assert planned.error_code == "capability_not_available" + + +def test_budget_blocks_repeated_identical_request() -> None: + handler = CountingHandler() + gateway = JudgeToolGateway(handlers={"oracle_lookup": handler}, cache_size=0) + budget = JudgeToolBudget(max_calls=3, max_repeated_request=1) + request = JudgeToolRequest( + tool="oracle_lookup", + arguments={"card_names": ["Young Wolf"]}, + ) + + first = gateway.execute(request, budget=budget) + second = gateway.execute(request, budget=budget) + + assert first.status is JudgeToolStatus.SUCCESS + assert second.status is JudgeToolStatus.BUDGET_EXCEEDED + assert second.error_code == "repeated_tool_request_blocked" + assert handler.calls == 1 + + +def main() -> int: + tests = [ + test_gateway_executes_and_caches_read_only_tool, + test_gateway_rejects_unknown_and_planned_tools, + test_budget_blocks_repeated_identical_request, + ] + for test in tests: + test() + print(f"OK: {test.__name__}") + print(f"Judge Tool Gateway tests: {len(tests)}/{len(tests)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/judge_tools/local_tools_test.py b/tests/judge_tools/local_tools_test.py new file mode 100644 index 0000000..ee3b28b --- /dev/null +++ b/tests/judge_tools/local_tools_test.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from magicai.conversation.models import Conversation, Message +from magicai.judge_tools.models import JudgeToolStatus +from magicai.judge_tools.tools import ( + ConversationContextTool, + LegalityCheckTool, + OracleLookupTool, + RulesLookupTool, + RulesSearchTool, + RulingsLookupTool, +) + + +YOUNG_WOLF = SimpleNamespace( + oracle_id="oracle-young-wolf", + id="printing-young-wolf", + name="Young Wolf", + mana_cost="{G}", + cmc=1.0, + type_line="Creature — Wolf", + oracle_text="Undying", + power="1", + toughness="1", + loyalty=None, + defense=None, + colors=["G"], + color_identity=["G"], + keywords=["Undying"], + legalities={"commander": "legal", "modern": "legal"}, + rarity="common", + released_at="2026-01-01", + scryfall_uri="https://example.invalid/young-wolf", + rulings_uri="https://example.invalid/rulings", +) + + +class FakeCardRepository: + def find_by_name(self, name): + return YOUNG_WOLF if name.casefold() == "young wolf" else None + + +class FakeRuleRepository: + def find_by_keyword(self, identifier): + if identifier.casefold() in {"702.93", "undying"}: + return { + "number": "702.93", + "title": "Undying", + "rules": [("702.93a", "Undying is a triggered ability.")], + } + return None + + def search(self, query, limit=5): + return [self.find_by_keyword("702.93")] if "undying" in query.casefold() else [] + + +class FakeRulingRepository: + def find_by_oracle_id(self, oracle_id, *, limit=8): + if oracle_id != "oracle-young-wolf": + return [] + return [ + { + "oracle_id": oracle_id, + "source": "scryfall", + "published_at": "2026-01-01", + "comment": "Test ruling.", + } + ][:limit] + + +def test_oracle_and_legality_tools_return_structured_evidence() -> None: + oracle = OracleLookupTool(FakeCardRepository())( + {"card_names": ["Young Wolf", "Missing Card"]}, + result_limit=8, + ) + legality = LegalityCheckTool(FakeCardRepository())( + {"card_names": ["Young Wolf"], "formats": ["commander"]}, + result_limit=8, + ) + + assert oracle.status is JudgeToolStatus.SUCCESS + assert oracle.evidence[0]["data"]["oracle_text"] == "Undying" + assert oracle.warnings + assert legality.evidence[0]["data"]["legalities"] == {"commander": "legal"} + + +def test_rules_and_rulings_tools_preserve_source_identifiers() -> None: + rules_lookup = RulesLookupTool(FakeRuleRepository())( + {"identifiers": ["702.93"]}, + result_limit=8, + ) + rules_search = RulesSearchTool(FakeRuleRepository())( + {"query": "undying trigger", "limit": 3}, + result_limit=8, + ) + rulings = RulingsLookupTool( + FakeCardRepository(), + FakeRulingRepository(), + )( + {"card_names": ["Young Wolf"]}, + result_limit=8, + ) + + assert rules_lookup.evidence[0]["identifier"] == "702.93" + assert rules_search.evidence[0]["data"]["rules"][0]["number"] == "702.93a" + assert rulings.evidence[0]["data"]["card_name"] == "Young Wolf" + + +def test_conversation_context_is_bounded_and_structured() -> None: + conversation = Conversation( + history=[ + Message(role="user", content="First"), + Message(role="assistant", content="Second"), + Message(role="user", content="Third"), + ], + active_cards=[YOUNG_WOLF], + active_rules=["702.93"], + last_intent="combo_detection", + ) + result = ConversationContextTool()( + {"include_history": True, "history_limit": 2}, + conversation=conversation, + result_limit=1, + ) + data = result.evidence[0]["data"] + assert data["active_cards"][0]["name"] == "Young Wolf" + assert data["active_rules"] == ["702.93"] + assert [item["content"] for item in data["recent_history"]] == ["Second", "Third"] + + +def main() -> int: + tests = [ + test_oracle_and_legality_tools_return_structured_evidence, + test_rules_and_rulings_tools_preserve_source_identifiers, + test_conversation_context_is_bounded_and_structured, + ] + for test in tests: + test() + print(f"OK: {test.__name__}") + print(f"Local Judge tool tests: {len(tests)}/{len(tests)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/tactician/tactician_core_test.py b/tests/tactician/tactician_core_test.py index f1e5438..b56d833 100644 --- a/tests/tactician/tactician_core_test.py +++ b/tests/tactician/tactician_core_test.py @@ -1,6 +1,7 @@ from types import SimpleNamespace from magicai.conversation.models import Conversation +from magicai.judge_tools.models import JudgeToolResult, JudgeToolStatus from magicai.tactician.core import Tactician @@ -45,9 +46,40 @@ def ask_result(self, conversation, question): ) +class FakeGateway: + def execute(self, request, *, conversation=None, budget=None): + source_cards = { + "Young Wolf": { + "name": "Young Wolf", + "mana_cost": "{G}", + "type_line": "Creature — Wolf", + "oracle_text": "Undying (When this creature dies, return it with a +1/+1 counter.)", + }, + "Carrion Feeder": { + "name": "Carrion Feeder", + "mana_cost": "{B}", + "type_line": "Creature — Zombie", + "oracle_text": "Sacrifice a creature: Put a +1/+1 counter on Carrion Feeder.", + }, + } + cards = [ + {"kind": "card", "identifier": name, "data": source_cards[name]} + for name in request.arguments.get("card_names", []) + ] + return JudgeToolResult( + tool=request.tool, + status=JudgeToolStatus.SUCCESS, + authority="official_card_data", + provider="local_scryfall_oracle", + purpose=request.purpose, + arguments=request.arguments, + evidence=cards, + ) + + def test_tactician_consumes_judge_package_without_direct_sources() -> None: conversation = Conversation() - result = Tactician(judge=FakeJudge()).ask_result( + result = Tactician(judge=FakeJudge(), tool_gateway=FakeGateway()).ask_result( conversation, "¿Young Wolf y Carrion Feeder son un combo?", ) @@ -56,9 +88,11 @@ def test_tactician_consumes_judge_package_without_direct_sources() -> None: assert payload["judge_result"]["authority"] == "judge" assert payload["authority_trace"] == [ "judge:factual_evidence", + "judge:tool_gateway", "tactician:strategic_interpretation", "judge:source_gateway", ] + assert payload["judge_tool_calls"][0]["status"] == "success" assert "sinergia de sacrificio" in payload["answer"] assert "No es un bucle infinito" in payload["answer"] assert conversation.mode == "tactician" diff --git a/tests/tactician/tactician_tool_gateway_test.py b/tests/tactician/tactician_tool_gateway_test.py new file mode 100644 index 0000000..4e4457a --- /dev/null +++ b/tests/tactician/tactician_tool_gateway_test.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from magicai.conversation.models import Conversation +from magicai.judge_tools.models import JudgeToolResult, JudgeToolStatus +from magicai.tactician.core import Tactician + + +class FakeJudge: + def ask_result(self, conversation, question): + return SimpleNamespace( + to_dict=lambda: { + "question": question, + "answer": "Strategy boundary.", + "status": "strategy_required", + "origin": "strategy_boundary", + "confidence": "high", + "authority": "judge", + "cards": [ + { + "name": "Young Wolf", + "type_line": "Creature — Wolf", + "oracle_text": "Undying", + } + ], + "rules": [], + "rulings": [], + "retrieval_queries": [], + "assumptions": [], + "warnings": [], + "source_versions": {}, + "source_health": {}, + } + ) + + +class FakeGateway: + def execute(self, request, *, conversation=None, budget=None): + assert request.tool == "oracle_lookup" + return JudgeToolResult( + tool=request.tool, + status=JudgeToolStatus.SUCCESS, + authority="official_card_data", + provider="local_scryfall_oracle", + purpose=request.purpose, + arguments=request.arguments, + evidence=[ + { + "kind": "card", + "identifier": "oracle-young-wolf", + "data": { + "oracle_id": "oracle-young-wolf", + "name": "Young Wolf", + "type_line": "Creature — Wolf", + "oracle_text": "Undying (When this creature dies, return it with a +1/+1 counter.)", + }, + } + ], + ) + + +def test_tactician_refreshes_evidence_through_judge_gateway() -> None: + conversation = Conversation() + result = Tactician(judge=FakeJudge(), tool_gateway=FakeGateway()).ask_result( + conversation, + "¿Tiene alguna sinergia Young Wolf?", + ) + payload = result.to_dict() + + assert payload["judge_tool_calls"][0]["tool"] == "oracle_lookup" + assert payload["judge_tool_calls"][0]["status"] == "success" + assert payload["cards"][0]["oracle_id"] == "oracle-young-wolf" + assert "judge:tool_gateway" in payload["authority_trace"] + assert payload["tactician_synthesized"] is True + + +def main() -> int: + test_tactician_refreshes_evidence_through_judge_gateway() + print("OK: test_tactician_refreshes_evidence_through_judge_gateway") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 0f6fa142015c6e9ab3aee16593fd7679d639ebd3 Mon Sep 17 00:00:00 2001 From: Manuel David Villalba Escamilla Date: Wed, 15 Jul 2026 14:24:04 +0200 Subject: [PATCH 4/7] Sprint 12.2b - Add Tactician input reasoning --- README.md | 653 +++++++++--------- docs/API_CONTRACT.md | 18 +- docs/ARCHITECTURE.md | 14 +- docs/COMMANDS.md | 3 + docs/JUDGE_TOOL_GATEWAY.md | 6 + docs/ROADMAP.md | 18 +- docs/STATUS.md | 37 +- docs/TACTICIAN.md | 13 +- docs/TACTICIAN_REASONING.md | 74 ++ magicai/api/schemas.py | 7 + magicai/conversation/models.py | 3 + magicai/conversation/repository.py | 2 + magicai/tactician/claims.py | 141 ++++ magicai/tactician/core.py | 260 ++++--- magicai/tactician/input_analysis.py | 231 +++++++ magicai/tactician/intents.py | 48 ++ magicai/tactician/models.py | 14 + magicai/tactician/planner.py | 86 +++ magicai/tactician/synthesis.py | 252 +++++++ magicai/ui/static/app.js | 6 + magicai/versioning.py | 4 +- scripts/ci_check.py | 3 + tests/api/tactician_api_contract_test.py | 168 ++--- tests/api/tactician_auto_handoff_test.py | 2 +- .../tactician_strategy_context_test.py | 33 + tests/tactician/tactician_core_test.py | 36 +- .../tactician_followup_reasoning_test.py | 86 +++ .../tactician_input_reasoning_test.py | 171 +++++ .../tactician/tactician_tool_gateway_test.py | 34 +- tests/ui/ui_usability_test.py | 347 +++++----- 30 files changed, 2063 insertions(+), 707 deletions(-) create mode 100644 docs/TACTICIAN_REASONING.md create mode 100644 magicai/tactician/claims.py create mode 100644 magicai/tactician/input_analysis.py create mode 100644 magicai/tactician/planner.py create mode 100644 magicai/tactician/synthesis.py create mode 100644 tests/conversation/tactician_strategy_context_test.py create mode 100644 tests/tactician/tactician_followup_reasoning_test.py create mode 100644 tests/tactician/tactician_input_reasoning_test.py diff --git a/README.md b/README.md index 75d5e35..60289a1 100644 --- a/README.md +++ b/README.md @@ -1,325 +1,328 @@ -
- -MagicAI - -# MagicAI - -### More Gathering. Less Guessing. - -**Local, source-grounded assistant for Magic: The Gathering** - -`v0.1.1-beta` — **Force of Will** · Local-first · Python + Ollama - -**Next public milestone:** `v0.2.0-beta` — **Ponder** -**Planned 1.0 codename:** **NicolAI Bolas** - -
- ---- - -## What is MagicAI? - -MagicAI is a local AI project for **Magic: The Gathering**. Its factual core is the **Judge**, which retrieves Oracle text, Comprehensive Rules, rulings, and conversation context before answering. - -> The model is not the source of truth. The Judge retrieves and validates evidence; language models explain it. - -MagicAI does not attempt to memorize every card or rule. It builds the evidence required for each question, uses deterministic renderers when a formal answer is covered, validates model output, and falls back safely when the evidence is insufficient. - -The second profile is the **Tactician**—shown as **Estratega** in the local UI. The Tactician analyzes game lines, interactions, synergies, and combos, but all factual data still passes through the Judge-owned source gateway. - -## Current capabilities - -- Local Scryfall Oracle and rulings snapshots. -- Local Magic Comprehensive Rules. -- Card, keyword, symbol, action, and rule retrieval. -- Conversation continuity and card disambiguation. -- Deterministic answers for covered rule families. -- Ollama fallback for explanations outside deterministic coverage. -- Source-grounded validation, retries, and safe fallback. -- Structured `JudgeResult` evidence and provenance. -- Local FastAPI REST API and browser UI. -- Persistent local conversation history in SQLite. -- Exportable community-feedback cases for evaluation only. -- Reproducible Gauntlets, multi-seed campaigns, process workers, sharding, and resume support. -- Tactician review of Judge contradictions. -- Automatic Judge-to-Tactician handoff for strategic questions. -- Referential follow-up support, including cards inherited from the previous turn. -- Initial generic combo reconstruction for sacrifice, Undying, counter-removal, token, and mana loops. - -## Card scope - -The standard Judge and evaluation catalog focus on ordinary paper cards. Funny, silver-border, acorn, and playtest cards are excluded, together with supplemental objects such as Vanguard cards, tokens, emblems, planes, phenomena, and schemes. Ordinary paper cards remain queryable even when they are currently banned. - -## Development status - -MagicAI is an early public beta with an advanced Judge core and an integrated Tactician foundation. The quality infrastructure is mature enough to expose semantic false positives instead of merely checking surface matches, but arbitrary Magic interactions are not yet fully covered. - -The latest focused C1.4 validation recorded: - -```text -Focused and expanded tests 231/231 -Full-Oracle smoke 42/42 -C1.3 finding replays 23/23 -Rebuilt campaign 1,000/1,000 -WARN 0 -FAIL 0 -``` - -These results describe a controlled and reproducible matrix. They do **not** mean every Magic card, rule, or interaction is covered. - -The current Tactician milestone adds: - -```text -Automatic strategic handoff -Conversation-card inheritance -Intent-specific strategy routing -Generic three-piece Undying loop detection -Judge capability registry -Structured combo steps and outcomes -``` - -See [docs/STATUS.md](docs/STATUS.md) for the current snapshot and [docs/ROADMAP.md](docs/ROADMAP.md) for the path to **Ponder**. - ---- - -## Project principles - -- **Judge authority:** the Judge is the sole factual authority for Oracle text, rules, rulings, and legality. -- **Tactician autonomy:** the Tactician may investigate iteratively and request as many Judge-owned tools as needed. -- **Source gateway:** strategic profiles never open factual sources directly. -- **Retrieve, do not memorize:** current sources take precedence over model memory. -- **No card-specific patches:** fixes must be generic, inspectable, and reusable. -- **Local-first:** inference runs through Ollama on the user's machine. -- **Safe uncertainty:** insufficient evidence is better than invented certainty. -- **Test the premise:** a correct answer is useless when the generated scenario is invalid. -- **Evaluation is not training:** reports and feedback artifacts never modify model weights automatically. - -See [docs/PHILOSOPHY.md](docs/PHILOSOPHY.md). - ---- - -## Architecture overview - -```text -User / API / UI - │ - ▼ -Conversation and intent routing - │ - ├──────── factual question ───────► Judge - │ │ - │ ├─ Oracle / Scryfall rulings - │ ├─ Comprehensive Rules - │ ├─ symbology / legality - │ └─ deterministic validation - │ - └──────── strategic question ─────► automatic handoff - │ - ▼ - Tactician - plan / combo / line - │ - ▼ - Judge source gateway - │ - ▼ - challenge / verify / critic - │ - ▼ - final answer -``` - -The Tactician may make repeated structured requests through the Judge. The source boundary is a trust boundary, not an intelligence limit. - -See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/TACTICIAN.md](docs/TACTICIAN.md). - ---- - -## Requirements - -- Python **3.12+** -- Ollama reachable over HTTP -- Recommended model: **Qwen3 8B** -- `curl`, `wget`, and `jq` for source download scripts -- Linux, WSL2, or an equivalent environment - -Default environment: - -```text -OLLAMA_URL=http://127.0.0.1:11434/api/chat -MAGICAI_MODEL=qwen3:8b -``` - ---- - -## Quick start - -```bash -git clone https://github.com/Fartis/MagicAI.git -cd MagicAI - -python3.12 -m venv .venv -source .venv/bin/activate -python -m pip install --upgrade pip -python -m pip install -r requirements.txt - -./scripts/download_sources.sh -./scripts/download_rules.sh -python scripts/update_scryfall_symbology.py - -ollama pull qwen3:8b -python -m uvicorn magicai.api:app --reload -``` - -Open: - -```text -UI http://127.0.0.1:8000/ui -API http://127.0.0.1:8000/docs -``` - -The complete setup guide, including `main` versus `develop`, Docker Ollama, and LAN Ollama, is in [docs/QUICKSTART.md](docs/QUICKSTART.md). - ---- - -## API profiles - -```text -POST /ask Judge entry point with automatic strategic handoff -POST /tactician/ask Explicit Tactician entry point -GET /meta contracts, profiles, codenames, and Judge capabilities -GET /health source and service health -``` - -Automatic handoff can be disabled for diagnostic clients: - -```json -{ - "question": "Do these cards form a combo?", - "auto_handoff": false -} -``` - -See [docs/API_CONTRACT.md](docs/API_CONTRACT.md) and [docs/JUDGE_RESULT.md](docs/JUDGE_RESULT.md). - ---- - -## Testing - -Fast pull request checks: - -```bash -python scripts/ci_check.py -``` - -Focused deterministic tests: - -```bash -PYTHONPATH=. python -m tests.validation.rule_renderer_test -PYTHONPATH=. python -m tests.validation.oracle_renderer_test -PYTHONPATH=. python -m tests.tactician.tactician_reviewer_test -PYTHONPATH=. python -m tests.tactician.tactician_strategy_test -PYTHONPATH=. python -m tests.tactician.tactician_conversation_handoff_test -``` - -Exhaustive Oracle evaluation: - -```bash -PYTHONPATH=. python -u -m tests.quality.oracle_exhaustive_test \ - --workers 4 \ - --shard-size 250 \ - --output-dir quality-results/oracle-exhaustive -``` - -See [docs/COMMANDS.md](docs/COMMANDS.md) and [docs/DEV_COMMANDS.md](docs/DEV_COMMANDS.md). - ---- - -## Main structure - -```text -MagicAI/ -├── magicai/ -│ ├── api/ # REST API -│ ├── assistant/ # Judge orchestration -│ ├── conversation/ # sessions and continuity -│ ├── judge_tools/ # Judge-owned capability registry -│ ├── retrieval/ # rule and Oracle query construction -│ ├── tactician/ # strategic analysis and challenges -│ ├── validation/ # renderers, validation, and fallback -│ └── ui/ # local browser UI -├── tests/ -├── docs/ -├── scripts/ -├── sources/ -└── database/ -``` - ---- - -## Release names - -- **0.1.1 beta — Force of Will:** the first public beta milestone. -- **0.2.0 beta — Ponder:** iterative Judge tools and a more autonomous Strategist. -- **1.0 — NicolAI Bolas:** the planned complete first major release. - -The codenames do not change MagicAI's source-grounded architecture or licensing. - ---- - -## Documentation - -- [Architecture](docs/ARCHITECTURE.md) -- [Quick start](docs/QUICKSTART.md) -- [Commands](docs/COMMANDS.md) -- [Developer commands](docs/DEV_COMMANDS.md) -- [UI](docs/UI.md) -- [Current status](docs/STATUS.md) -- [JudgeResult](docs/JUDGE_RESULT.md) -- [API contract](docs/API_CONTRACT.md) -- [Roadmap](docs/ROADMAP.md) -- [Tactician](docs/TACTICIAN.md) -- [Philosophy](docs/PHILOSOPHY.md) -- [Contributing](docs/CONTRIBUTING.md) -- [Branching policy](docs/BRANCHING.md) -- [Release process](docs/RELEASE_PROCESS.md) -- [Repository health](docs/REPOSITORY_HEALTH.md) - ---- - -## License - -MagicAI is distributed under the **GNU Affero General Public License v3.0 or later** (`AGPL-3.0-or-later`). See [LICENSE](LICENSE) and [docs/LICENSING.md](docs/LICENSING.md). - ---- - -# ❤️ A personal letter - -If you've made it this far, chances are we share the same passion. - -I'd like to finish this README with a few personal words. - -Due to health reasons, this will most likely be the last major software project I'll be able to build. After spending a large part of my professional life developing software, I've had to accept that my journey will take a different path much sooner than I ever expected. - -I wanted to say goodbye to this chapter by creating something that brought together the two things I've loved the most throughout my life: programming and **Magic: The Gathering**, a game that has been with me for as long as I can remember and has always meant far more than just a game. - -MagicAI was born from a simple idea: to build the tool I always wished I had, one that could help me understand the rules, organize my thoughts and continue enjoying this incredible game. - -As long as my health allows it, I'll continue improving it little by little, learning and adding new features whenever I can. - -If this project helps even a single player solve a rules question, discover a new interaction or simply enjoy Magic a little bit more, then it will have achieved everything I hoped for. - -Thank you for taking the time to discover this project. - -I truly hope you enjoy using it as much as I've enjoyed building it. - -**See you in the next game.** - - ---- - -
- -### 🧙 More Gathering. Less Guessing. - -
+
+ +MagicAI + +# MagicAI + +### More Gathering. Less Guessing. + +**Local, source-grounded assistant for Magic: The Gathering** + +`v0.1.1-beta` — **Force of Will** · Local-first · Python + Ollama + +**Next public milestone:** `v0.2.0-beta` — **Ponder** +**Planned 1.0 codename:** **NicolAI Bolas** + +
+ +--- + +## What is MagicAI? + +MagicAI is a local AI project for **Magic: The Gathering**. Its factual core is the **Judge**, which retrieves Oracle text, Comprehensive Rules, rulings, and conversation context before answering. + +> The model is not the source of truth. The Judge retrieves and validates evidence; language models explain it. + +MagicAI does not attempt to memorize every card or rule. It builds the evidence required for each question, uses deterministic renderers when a formal answer is covered, validates model output, and falls back safely when the evidence is insufficient. + +The second profile is the **Tactician**—shown as **Estratega** in the local UI. The Tactician analyzes game lines, interactions, synergies, and combos, but all factual data still passes through the Judge-owned source gateway. + +## Current capabilities + +- Local Scryfall Oracle and rulings snapshots. +- Local Magic Comprehensive Rules. +- Card, keyword, symbol, action, and rule retrieval. +- Conversation continuity and card disambiguation. +- Deterministic answers for covered rule families. +- Ollama fallback for explanations outside deterministic coverage. +- Source-grounded validation, retries, and safe fallback. +- Structured `JudgeResult` evidence and provenance. +- Local FastAPI REST API and browser UI. +- Persistent local conversation history in SQLite. +- Exportable community-feedback cases for evaluation only. +- Reproducible Gauntlets, multi-seed campaigns, process workers, sharding, and resume support. +- Tactician review of Judge contradictions. +- Automatic Judge-to-Tactician handoff for strategic questions. +- Referential follow-up support, including cards inherited from the previous turn. +- Initial generic combo reconstruction for sacrifice, Undying, counter-removal, token, and mana loops. + +## Card scope + +The standard Judge and evaluation catalog focus on ordinary paper cards. Funny, silver-border, acorn, and playtest cards are excluded, together with supplemental objects such as Vanguard cards, tokens, emblems, planes, phenomena, and schemes. Ordinary paper cards remain queryable even when they are currently banned. + +## Development status + +MagicAI is an early public beta with an advanced Judge core and an integrated Tactician foundation. The quality infrastructure is mature enough to expose semantic false positives instead of merely checking surface matches, but arbitrary Magic interactions are not yet fully covered. + +The latest focused C1.4 validation recorded: + +```text +Focused and expanded tests 231/231 +Full-Oracle smoke 42/42 +C1.3 finding replays 23/23 +Rebuilt campaign 1,000/1,000 +WARN 0 +FAIL 0 +``` + +These results describe a controlled and reproducible matrix. They do **not** mean every Magic card, rule, or interaction is covered. + +The current Tactician milestone adds: + +```text +Automatic strategic handoff +Conversation-card inheritance +Structured input and claim analysis +Bounded multi-tool investigation plans +Claim verdicts with source identifiers +Independent conversational synthesis +Strategic follow-up intents and persisted context +Generic three-piece Undying loop detection +Structured combo steps and outcomes +``` + +See [docs/STATUS.md](docs/STATUS.md) for the current snapshot and [docs/ROADMAP.md](docs/ROADMAP.md) for the path to **Ponder**. + +--- + +## Project principles + +- **Judge authority:** the Judge is the sole factual authority for Oracle text, rules, rulings, and legality. +- **Tactician autonomy:** the Tactician may investigate iteratively and request as many Judge-owned tools as needed. +- **Source gateway:** strategic profiles never open factual sources directly. +- **Retrieve, do not memorize:** current sources take precedence over model memory. +- **No card-specific patches:** fixes must be generic, inspectable, and reusable. +- **Local-first:** inference runs through Ollama on the user's machine. +- **Safe uncertainty:** insufficient evidence is better than invented certainty. +- **Test the premise:** a correct answer is useless when the generated scenario is invalid. +- **Evaluation is not training:** reports and feedback artifacts never modify model weights automatically. + +See [docs/PHILOSOPHY.md](docs/PHILOSOPHY.md). + +--- + +## Architecture overview + +```text +User / API / UI + │ + ▼ +Conversation and intent routing + │ + ├──────── factual question ───────► Judge + │ │ + │ ├─ Oracle / Scryfall rulings + │ ├─ Comprehensive Rules + │ ├─ symbology / legality + │ └─ deterministic validation + │ + └──────── strategic question ─────► automatic handoff + │ + ▼ + Tactician + plan / combo / line + │ + ▼ + Judge source gateway + │ + ▼ + challenge / verify / critic + │ + ▼ + final answer +``` + +The Tactician may make repeated structured requests through the Judge. The source boundary is a trust boundary, not an intelligence limit. + +See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/TACTICIAN.md](docs/TACTICIAN.md). + +--- + +## Requirements + +- Python **3.12+** +- Ollama reachable over HTTP +- Recommended model: **Qwen3 8B** +- `curl`, `wget`, and `jq` for source download scripts +- Linux, WSL2, or an equivalent environment + +Default environment: + +```text +OLLAMA_URL=http://127.0.0.1:11434/api/chat +MAGICAI_MODEL=qwen3:8b +``` + +--- + +## Quick start + +```bash +git clone https://github.com/Fartis/MagicAI.git +cd MagicAI + +python3.12 -m venv .venv +source .venv/bin/activate +python -m pip install --upgrade pip +python -m pip install -r requirements.txt + +./scripts/download_sources.sh +./scripts/download_rules.sh +python scripts/update_scryfall_symbology.py + +ollama pull qwen3:8b +python -m uvicorn magicai.api:app --reload +``` + +Open: + +```text +UI http://127.0.0.1:8000/ui +API http://127.0.0.1:8000/docs +``` + +The complete setup guide, including `main` versus `develop`, Docker Ollama, and LAN Ollama, is in [docs/QUICKSTART.md](docs/QUICKSTART.md). + +--- + +## API profiles + +```text +POST /ask Judge entry point with automatic strategic handoff +POST /tactician/ask Explicit Tactician entry point +GET /meta contracts, profiles, codenames, and Judge capabilities +GET /health source and service health +``` + +Automatic handoff can be disabled for diagnostic clients: + +```json +{ + "question": "Do these cards form a combo?", + "auto_handoff": false +} +``` + +See [docs/API_CONTRACT.md](docs/API_CONTRACT.md) and [docs/JUDGE_RESULT.md](docs/JUDGE_RESULT.md). + +--- + +## Testing + +Fast pull request checks: + +```bash +python scripts/ci_check.py +``` + +Focused deterministic tests: + +```bash +PYTHONPATH=. python -m tests.validation.rule_renderer_test +PYTHONPATH=. python -m tests.validation.oracle_renderer_test +PYTHONPATH=. python -m tests.tactician.tactician_reviewer_test +PYTHONPATH=. python -m tests.tactician.tactician_strategy_test +PYTHONPATH=. python -m tests.tactician.tactician_conversation_handoff_test +``` + +Exhaustive Oracle evaluation: + +```bash +PYTHONPATH=. python -u -m tests.quality.oracle_exhaustive_test \ + --workers 4 \ + --shard-size 250 \ + --output-dir quality-results/oracle-exhaustive +``` + +See [docs/COMMANDS.md](docs/COMMANDS.md) and [docs/DEV_COMMANDS.md](docs/DEV_COMMANDS.md). + +--- + +## Main structure + +```text +MagicAI/ +├── magicai/ +│ ├── api/ # REST API +│ ├── assistant/ # Judge orchestration +│ ├── conversation/ # sessions and continuity +│ ├── judge_tools/ # Judge-owned capability registry +│ ├── retrieval/ # rule and Oracle query construction +│ ├── tactician/ # strategic analysis and challenges +│ ├── validation/ # renderers, validation, and fallback +│ └── ui/ # local browser UI +├── tests/ +├── docs/ +├── scripts/ +├── sources/ +└── database/ +``` + +--- + +## Release names + +- **0.1.1 beta — Force of Will:** the first public beta milestone. +- **0.2.0 beta — Ponder:** iterative Judge tools and a more autonomous Strategist. +- **1.0 — NicolAI Bolas:** the planned complete first major release. + +The codenames do not change MagicAI's source-grounded architecture or licensing. + +--- + +## Documentation + +- [Architecture](docs/ARCHITECTURE.md) +- [Quick start](docs/QUICKSTART.md) +- [Commands](docs/COMMANDS.md) +- [Developer commands](docs/DEV_COMMANDS.md) +- [UI](docs/UI.md) +- [Current status](docs/STATUS.md) +- [JudgeResult](docs/JUDGE_RESULT.md) +- [API contract](docs/API_CONTRACT.md) +- [Roadmap](docs/ROADMAP.md) +- [Tactician](docs/TACTICIAN.md) +- [Philosophy](docs/PHILOSOPHY.md) +- [Contributing](docs/CONTRIBUTING.md) +- [Branching policy](docs/BRANCHING.md) +- [Release process](docs/RELEASE_PROCESS.md) +- [Repository health](docs/REPOSITORY_HEALTH.md) + +--- + +## License + +MagicAI is distributed under the **GNU Affero General Public License v3.0 or later** (`AGPL-3.0-or-later`). See [LICENSE](LICENSE) and [docs/LICENSING.md](docs/LICENSING.md). + +--- + +# ❤️ A personal letter + +If you've made it this far, chances are we share the same passion. + +I'd like to finish this README with a few personal words. + +Due to health reasons, this will most likely be the last major software project I'll be able to build. After spending a large part of my professional life developing software, I've had to accept that my journey will take a different path much sooner than I ever expected. + +I wanted to say goodbye to this chapter by creating something that brought together the two things I've loved the most throughout my life: programming and **Magic: The Gathering**, a game that has been with me for as long as I can remember and has always meant far more than just a game. + +MagicAI was born from a simple idea: to build the tool I always wished I had, one that could help me understand the rules, organize my thoughts and continue enjoying this incredible game. + +As long as my health allows it, I'll continue improving it little by little, learning and adding new features whenever I can. + +If this project helps even a single player solve a rules question, discover a new interaction or simply enjoy Magic a little bit more, then it will have achieved everything I hoped for. + +Thank you for taking the time to discover this project. + +I truly hope you enjoy using it as much as I've enjoyed building it. + +**See you in the next game.** + + +--- + +
+ +### 🧙 More Gathering. Less Guessing. + +
diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index de13603..9a09e8a 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -1,11 +1,11 @@ # API contract -Current API contract version: `1.4`. +Current API contract version: `1.5`. ## Stable result families - Judge results use JudgeResult schema `1.0`. -- Tactician results use TacticianResult schema `0.3`. +- Tactician results use TacticianResult schema `0.4`. - Judge tool results use JudgeToolResult schema `1.0`. ## Main endpoints @@ -52,3 +52,17 @@ A `session_id` may be supplied only when `conversation_context` requires an exis - planned and permission-gated capabilities. Unavailable capabilities return a structured result instead of being guessed. + +## Tactician reasoning fields + +TacticianResult `0.4` adds: + +- `input_analysis` +- `claim_verdicts` +- `reasoning_summary` +- `queries_planned` +- `queries_completed` +- `judge_verified` +- `investigation_plan` + +These fields expose a concise, structured audit trail. They are not a hidden chain-of-thought transcript. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4034bc2..3e32dd5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -30,10 +30,12 @@ Request orchestrator │ └─ conversation context │ └─ Tactician - ├─ strategic intent classification - ├─ evidence-gap detection + ├─ speech-act and strategic intent analysis + ├─ claim extraction and verdicts + ├─ bounded investigation planning ├─ combo and synergy analysis ├─ Judge-tool requests + ├─ conversational synthesis ├─ Judge challenges └─ recommended lines and risks ``` @@ -88,3 +90,11 @@ Classification values: ## Resilience The Tactician should continue investigating when evidence is incomplete. The gateway supplies bounded query budgets, explicit capability failures, and source-aware caching. Missing tools must be reported rather than silently guessed. + +## Structured input reasoning + +The Tactician analyzes the user's message before requesting evidence. It records a compact audit trail containing the speech act, detected claims, planned queries, claim verdicts, and a concise reasoning summary. + +This is intentionally structured and testable. It is not a free-form chain-of-thought log. + +When the Judge already returns a factual answer, the Tactician still performs its own strategic synthesis rather than relaying that prose unchanged. diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 6dc5b98..84cba1f 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -31,6 +31,9 @@ python -m tests.validation.answer_validation_test python -m tests.tactician.tactician_reviewer_test python -m tests.tactician.tactician_strategy_test python -m tests.tactician.tactician_conversation_handoff_test +python -m tests.tactician.tactician_input_reasoning_test +python -m tests.tactician.tactician_followup_reasoning_test +python -m tests.conversation.tactician_strategy_context_test python -m tests.api.tactician_api_contract_test ``` diff --git a/docs/JUDGE_TOOL_GATEWAY.md b/docs/JUDGE_TOOL_GATEWAY.md index 4037ac6..cfef309 100644 --- a/docs/JUDGE_TOOL_GATEWAY.md +++ b/docs/JUDGE_TOOL_GATEWAY.md @@ -95,3 +95,9 @@ Conversation context is never cached. ## REST diagnostics `POST /judge/tools/execute` exposes the same read-only contract for local diagnostics and future UI tooling. Strategic code should use `JudgeToolGateway` rather than opening files or repositories directly. + +## Tactician planner use + +Sprint 12.2b can group several bounded requests under one investigation budget. The first implemented plans combine Oracle refresh, exact rules lookup, and official rulings lookup when a user hypothesis requires timing or zone-change verification. + +The planner records its goals and request list in `investigation_plan`. Missing or unavailable evidence remains explicit and reduces `judge_verified` rather than being silently guessed. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a8de9e8..000ec7c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -62,16 +62,20 @@ The first major release should add mature deck analysis, authorized strategic so - Read-only diagnostic REST endpoint. - Initial Tactician Oracle refresh through the gateway. -### 12.2b — Conversational strategic orchestration — next +### 12.2b — Input reasoning and conversational orchestration — complete -- `play_sequence`, `combo_execution_order`, `combo_disruption`, and related intents. -- Active strategic conversation state. +- Structured speech-act, intent, and claim analysis. +- Bounded multi-tool investigation plans. +- `play_sequence`, `combo_disruption`, `combo_requirements`, and `interaction_hypothesis` intents. +- Persisted active strategic conversation state. - Independent Tactician synthesis instead of Judge-answer relay. -- Warm, interactive response style. -- Smart ambiguity handling. -- Factual claim verification after synthesis. +- Warm, interactive response style for supported families. +- Claim verdicts and evidence identifiers. +- Evidence-verification metadata. +- Young Wolf, Carrion Feeder, and The Ozolith reasoning regression. +- Ghave and Ashnod's Altar follow-up sequencing and disruption regression. -### 12.3 — Autonomous investigation planner +### 12.3 — General autonomous investigation planner — next - Hypothesis decomposition. - Multiple Judge queries per user request. diff --git a/docs/STATUS.md b/docs/STATUS.md index 2bf0dcb..9bfafd4 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -9,34 +9,35 @@ ## Current development line -Sprint `12.2a` introduces the executable Judge Tool Gateway. +Sprint `12.2b` introduces structured input reasoning and conversational strategic orchestration. Completed in this milestone: -- typed read-only tool contracts; -- executable Oracle, rules, rulings, legality, and conversation-context tools; -- source provenance and version reporting; -- bounded shared investigation budgets; -- in-memory source-aware cache; -- explicit unavailable responses for planned providers; -- diagnostic REST endpoint; -- first Tactician evidence refresh through the gateway; -- focused source-independent CI coverage. +- speech-act and strategic-intent analysis; +- deterministic claim extraction; +- bounded multi-tool investigation plans; +- claim verdicts with source identifiers; +- independent Tactician synthesis instead of Judge-answer relay; +- conversational correction of user hypotheses; +- persisted strategic conversation context; +- play-sequence, disruption, and requirements follow-ups; +- TacticianResult schema `0.4` and API contract `1.5`; +- focused input-reasoning and conversation-state regressions. ## Next development line -Sprint `12.2b` will focus on conversational strategic orchestration: +Sprint `12.3` will focus on general autonomous investigation planning: -- play-sequence and combo-follow-up intents; -- active strategic state; -- independent Tactician synthesis; -- warmer interactive responses; -- smart ambiguity handling; -- factual claim verification after synthesis. +- broader claim decomposition; +- iterative evidence-gap detection; +- evidence sufficiency scoring; +- alternative and counterexample search; +- configurable investigation depth; +- wider strategic families beyond the current deterministic vertical slices. ## Known limitations -- The Tactician does not yet plan arbitrary multi-query investigations. +- Multi-query planning is implemented for supported concepts, but is not yet general across arbitrary interactions. - Combo reconstruction covers only a narrow generic family. - Commander Spellbook is not connected. - EDHREC-style statistics remain permission-gated. diff --git a/docs/TACTICIAN.md b/docs/TACTICIAN.md index 94ce639..05c020e 100644 --- a/docs/TACTICIAN.md +++ b/docs/TACTICIAN.md @@ -15,7 +15,7 @@ The Tactician analyzes: It does not open Oracle, rules, rulings, Commander Spellbook, EDHREC, or user collection files directly. It requests structured evidence through the Judge Tool Gateway. -## Current milestone: 0.3 +## Current milestone: 0.4 Implemented: @@ -29,7 +29,14 @@ Implemented: - executable, typed Judge Tool Gateway; - bounded Oracle refresh through the Judge before strategic synthesis; - tool-call provenance in `judge_tool_calls` and `judge_queries`; -- explicit `tactician_synthesized` metadata. +- explicit `tactician_synthesized` metadata; +- speech-act, intent, and claim analysis for user input; +- bounded multi-tool investigation plans; +- claim verdicts with evidence identifiers; +- independent conversational synthesis even when the Judge already returned a factual answer; +- persisted strategic conversation context; +- `play_sequence`, `combo_disruption`, `combo_requirements`, and `interaction_hypothesis` intents; +- evidence-verification metadata and concise reasoning summaries. ## Evidence loop @@ -46,7 +53,7 @@ Tactician forms a hypothesis → publish or declare uncertainty ``` -Milestone 0.3 provides the executable gateway and a first bounded Oracle refresh. Autonomous multi-query planning remains the next milestone. +Milestone 0.4 provides deterministic claim extraction, bounded multi-tool planning, evidence verdicts, and conversational synthesis. General autonomous planning remains the next milestone. ## Personality diff --git a/docs/TACTICIAN_REASONING.md b/docs/TACTICIAN_REASONING.md new file mode 100644 index 0000000..d192aef --- /dev/null +++ b/docs/TACTICIAN_REASONING.md @@ -0,0 +1,74 @@ +# Tactician input reasoning + +Sprint 12.2b adds a structured reasoning layer between the user's message and strategic synthesis. + +## Goal + +The Tactician must not treat the Judge's prose as the answer. It first analyzes what the player is asserting, asking, correcting, or challenging, then requests the evidence needed to evaluate those claims. + +## Pipeline + +```text +user input + → speech-act and intent analysis + → claim extraction + → bounded investigation plan + → Judge Tool Gateway calls + → claim verdicts + → conversational strategic synthesis + → evidence verification metadata +``` + +## Input analysis + +The analyzer records: + +- language; +- speech act such as question, hypothesis, challenge, or follow-up; +- strategic intent; +- causal markers; +- detected concepts; +- structured claims. + +The result is auditable metadata, not a hidden chain-of-thought transcript. + +## Claim verdicts + +Each extracted claim can be classified as: + +- `supported` +- `contradicted` +- `partially_supported` +- `insufficient_evidence` +- `strategic_opinion` + +A verdict records a concise explanation and the source identifiers that support it. + +## Current deterministic interaction family + +The first full reasoning family covers: + +- Young Wolf; +- Carrion Feeder; +- The Ozolith; +- Undying; +- counters across zone changes; +- leaves-the-battlefield timing; +- last known information. + +The Tactician correctly explains that The Ozolith does not remove Young Wolf's +1/+1 counter before Undying checks the creature's last battlefield state. + +## Strategic follow-ups + +The active strategic context now supports follow-up intents such as: + +- `play_sequence` +- `combo_disruption` +- `combo_requirements` +- `interaction_hypothesis` + +The first supported sequence family covers Young Wolf, Ashnod's Altar, and Ghave, Guru of Spores. + +## Limits + +This milestone is not a universal natural-language theorem prover. Claim extraction and verdict generation are deterministic and intentionally narrow. Sprint 12.3 will generalize investigation planning, evidence sufficiency, alternatives, and counterexample search. diff --git a/magicai/api/schemas.py b/magicai/api/schemas.py index 7b16d15..026d006 100644 --- a/magicai/api/schemas.py +++ b/magicai/api/schemas.py @@ -70,6 +70,13 @@ class AskResponse(BaseModel): judge_queries: list[dict[str, Any]] = Field(default_factory=list) judge_tool_calls: list[dict[str, Any]] = Field(default_factory=list) tactician_synthesized: bool = False + input_analysis: dict[str, Any] = Field(default_factory=dict) + claim_verdicts: list[dict[str, Any]] = Field(default_factory=list) + reasoning_summary: list[str] = Field(default_factory=list) + queries_planned: int = 0 + queries_completed: int = 0 + judge_verified: bool = False + investigation_plan: dict[str, Any] = Field(default_factory=dict) judge_result: dict[str, Any] = Field(default_factory=dict) diff --git a/magicai/conversation/models.py b/magicai/conversation/models.py index 4e1ae56..e01a0c3 100644 --- a/magicai/conversation/models.py +++ b/magicai/conversation/models.py @@ -37,6 +37,9 @@ class Conversation: mode: str = "assistant" + # Structured strategic state used by the Tactician for natural follow-ups. + strategy_context: dict = field(default_factory=dict) + def add_user_message(self, text: str): self.history.append( diff --git a/magicai/conversation/repository.py b/magicai/conversation/repository.py index 8fd9be6..179cae4 100644 --- a/magicai/conversation/repository.py +++ b/magicai/conversation/repository.py @@ -275,6 +275,7 @@ def _conversation_to_dict(conversation: Conversation) -> dict[str, Any]: "last_intent": str(conversation.last_intent or ""), "language": str(conversation.language or "es"), "mode": str(conversation.mode or "assistant"), + "strategy_context": _json_safe(conversation.strategy_context), } @@ -306,6 +307,7 @@ def _conversation_from_dict(value: dict[str, Any]) -> Conversation: last_intent=str(value.get("last_intent") or ""), language=str(value.get("language") or "es"), mode=str(value.get("mode") or "assistant"), + strategy_context=(value.get("strategy_context") if isinstance(value.get("strategy_context"), dict) else {}), ) diff --git a/magicai/tactician/claims.py b/magicai/tactician/claims.py new file mode 100644 index 0000000..8b31a73 --- /dev/null +++ b/magicai/tactician/claims.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Any + +from magicai.tactician.input_analysis import ClaimKind, InputAnalysis, InputClaim + + +class ClaimVerdictStatus(str, Enum): + SUPPORTED = "supported" + CONTRADICTED = "contradicted" + PARTIALLY_SUPPORTED = "partially_supported" + INSUFFICIENT_EVIDENCE = "insufficient_evidence" + STRATEGIC_OPINION = "strategic_opinion" + + +@dataclass(frozen=True, slots=True) +class ClaimVerdict: + claim_id: str + claim: str + status: ClaimVerdictStatus + explanation: str + evidence: tuple[str, ...] = () + + def to_dict(self) -> dict[str, object]: + return { + "claim_id": self.claim_id, + "claim": self.claim, + "verdict": self.status.value, + "explanation": self.explanation, + "evidence": list(self.evidence), + } + + +def evaluate_claims( + analysis: InputAnalysis, + *, + cards: list[dict[str, Any]], + tool_calls: list[dict[str, Any]], +) -> list[ClaimVerdict]: + evidence_ids = _evidence_identifiers(tool_calls) + oracle_by_name = { + str(card.get("name", "")).casefold(): str(card.get("oracle_text", "")) + for card in cards + if card.get("name") + } + has_ozolith = "the ozolith" in oracle_by_name + has_undying = any("undying" in oracle.casefold() for oracle in oracle_by_name.values()) + + verdicts: list[ClaimVerdict] = [] + for claim in analysis.claims: + verdicts.append( + _evaluate_claim( + claim, + has_ozolith=has_ozolith, + has_undying=has_undying, + evidence_ids=evidence_ids, + ) + ) + return verdicts + + +def _evaluate_claim( + claim: InputClaim, + *, + has_ozolith: bool, + has_undying: bool, + evidence_ids: set[str], +) -> ClaimVerdict: + concepts = set(claim.concepts) + + if claim.kind is ClaimKind.STATE_TRANSITION and "sacrifice" in concepts: + return ClaimVerdict( + claim_id=claim.claim_id, + claim=claim.text, + status=ClaimVerdictStatus.SUPPORTED, + explanation="Sacrificing a permanent moves it from the battlefield to its owner's graveyard; a creature moved this way dies.", + evidence=_prefer_evidence(evidence_ids, "701.21a", "700.4"), + ) + + if claim.kind is ClaimKind.TIMING_HYPOTHESIS and has_ozolith and "counters" in concepts: + return ClaimVerdict( + claim_id=claim.claim_id, + claim=claim.text, + status=ClaimVerdictStatus.CONTRADICTED, + explanation=( + "The Ozolith does not remove or transfer the original counter before the zone change. " + "The counter ceases to exist when the object changes zones, and The Ozolith's triggered ability puts corresponding counters on itself later." + ), + evidence=_prefer_evidence(evidence_ids, "122.2", "400.7", "603.6c", "603.10a"), + ) + + if claim.kind is ClaimKind.DERIVED_CONCLUSION and has_undying: + return ClaimVerdict( + claim_id=claim.claim_id, + claim=claim.text, + status=ClaimVerdictStatus.CONTRADICTED, + explanation=( + "Undying uses the creature's last battlefield state for the leaves-the-battlefield trigger. " + "If Young Wolf had a +1/+1 counter immediately before it left, the intervening-if condition is false and Undying does not trigger." + ), + evidence=_prefer_evidence(evidence_ids, "702.93a", "603.4", "603.10a"), + ) + + if claim.kind is ClaimKind.STRATEGIC: + return ClaimVerdict( + claim_id=claim.claim_id, + claim=claim.text, + status=ClaimVerdictStatus.STRATEGIC_OPINION, + explanation="Whether the pieces form a combo must be derived from the verified state transitions and net result.", + evidence=(), + ) + + return ClaimVerdict( + claim_id=claim.claim_id, + claim=claim.text, + status=ClaimVerdictStatus.INSUFFICIENT_EVIDENCE, + explanation="The current evidence package does not prove or disprove this claim yet.", + evidence=(), + ) + + +def _evidence_identifiers(tool_calls: list[dict[str, Any]]) -> set[str]: + identifiers: set[str] = set() + for call in tool_calls: + for item in call.get("evidence", []): + identifier = str(item.get("identifier", "")).strip() + if identifier: + identifiers.add(identifier) + data = item.get("data", {}) + if isinstance(data, dict): + number = str(data.get("number", "")).strip() + if number: + identifiers.add(number) + return identifiers + + +def _prefer_evidence(available: set[str], *identifiers: str) -> tuple[str, ...]: + selected = [identifier for identifier in identifiers if identifier in available] + return tuple(selected or identifiers) diff --git a/magicai/tactician/core.py b/magicai/tactician/core.py index 6e01f25..c59c58c 100644 --- a/magicai/tactician/core.py +++ b/magicai/tactician/core.py @@ -10,12 +10,15 @@ JudgeToolRequest, JudgeToolStatus, ) -from magicai.tactician.intents import ( - classify_strategy_intent, - looks_like_referential_follow_up, +from magicai.tactician.claims import ( + ClaimVerdictStatus, + evaluate_claims, ) +from magicai.tactician.input_analysis import analyze_user_input +from magicai.tactician.intents import looks_like_referential_follow_up from magicai.tactician.models import TacticianResult -from magicai.tactician.strategy import analyze_strategy +from magicai.tactician.planner import plan_investigation +from magicai.tactician.synthesis import synthesize_strategy class Tactician: @@ -44,7 +47,7 @@ def ask_result(self, conversation, question: str) -> TacticianResult: ) _copy_state(judge_conversation, conversation) - conversation.active_cards = deepcopy(result.cards) + _apply_result_state(conversation, result) conversation.add_user_message(question) conversation.add_assistant_message(result.answer) conversation.mode = "tactician" @@ -58,66 +61,93 @@ def from_judge_result( prior_cards: list[Any] | None = None, conversation=None, ) -> TacticianResult: - """Build a strategic result from a Judge package. + """Investigate the user's input and synthesize a strategic answer. - The Tactician may request bounded, read-only evidence through the - Judge Tool Gateway. It never opens Oracle, rules, rulings, or future - strategic providers directly. + The Tactician can issue multiple bounded requests through the Judge Tool + Gateway. It never opens Oracle, rules, rulings, or strategic providers + directly, and it never treats the Judge's prose as a ready-made + strategic response. """ + input_analysis = analyze_user_input(question) judge_payload = judge_result.to_dict() - intent = classify_strategy_intent(question) merged_cards, inherited_names = _merge_context_cards( current=judge_payload.get("cards", []), previous=prior_cards or [], - inherit=looks_like_referential_follow_up(question), + inherit=( + looks_like_referential_follow_up(question) + or input_analysis.speech_act.value in {"challenge", "follow_up"} + or input_analysis.strategy_intent.value in {"play_sequence", "combo_disruption", "combo_requirements"} + ), ) + budget = JudgeToolBudget( + max_calls=8, + max_calls_per_tool=3, + max_repeated_request=1, + max_elapsed_seconds=30.0, + ) tool_calls: list[dict[str, Any]] = [] if conversation is not None and merged_cards: - merged_cards, tool_calls = self._refresh_oracle_evidence( + merged_cards, oracle_calls = self._refresh_oracle_evidence( cards=merged_cards, conversation=conversation, + budget=budget, ) + tool_calls.extend(oracle_calls) + + plan = plan_investigation( + input_analysis, + cards=merged_cards, + oracle_already_refreshed=bool(tool_calls), + ) + if conversation is not None and plan.requests: + for request in plan.requests: + result = self.execute_judge_tool( + request, + conversation=conversation, + budget=budget, + ) + tool_calls.append(result.to_dict()) + + judge_payload = _merge_tool_evidence( + {**judge_payload, "cards": merged_cards}, + tool_calls, + ) + claim_verdicts = evaluate_claims( + input_analysis, + cards=merged_cards, + tool_calls=tool_calls, + ) + + synthesis = synthesize_strategy( + question=question, + judge_payload=judge_payload, + input_analysis=input_analysis, + claim_verdicts=claim_verdicts, + ) - judge_payload = {**judge_payload, "cards": merged_cards} judge_status = str(judge_payload.get("status", "")) - if judge_status == "strategy_required": - analysis = analyze_strategy( - question, - judge_payload, - intent=intent, - ) - answer = analysis.answer - status = "answered" - origin = "tactician_strategy" - confidence = _strategy_confidence( - analysis.combo_classification, - judge_payload, - ) - synergies = analysis.synergies - risks = analysis.risks - combo_classification = analysis.combo_classification - combo_steps = analysis.combo_steps - outcomes = analysis.outcomes - tactician_synthesized = True - else: - answer = str(judge_payload.get("answer", "")) - synergies = [] - risks = [] - combo_classification = "not_applicable" - combo_steps = [] - outcomes = [] + if judge_status not in {"strategy_required", "answered"}: status = judge_status or "insufficient_evidence" - origin = "tactician_judge_gate" - confidence = str(judge_payload.get("confidence", "low")) - tactician_synthesized = False + warnings = [ + *list(judge_payload.get("warnings", [])), + "The Judge could not close the factual package; the Strategist's conclusion is limited to the evidence recovered so far.", + ] + else: + status = "answered" + warnings = list(judge_payload.get("warnings", [])) - warnings = list(judge_payload.get("warnings", [])) - if judge_status not in {"strategy_required", "answered"}: - warnings.append( - "The Judge could not close the factual package, so the Tactician did not add a strategic recommendation." - ) + judge_verified = _judge_verified( + claim_verdicts=claim_verdicts, + combo_classification=synthesis.combo_classification, + tool_calls=tool_calls, + ) + confidence = _strategy_confidence( + synthesis.combo_classification, + judge_payload, + judge_verified=judge_verified, + ) judge_queries = [ { @@ -140,44 +170,51 @@ def from_judge_result( } ) - if origin == "tactician_strategy": - authority_trace = ["judge:factual_evidence"] - if tool_calls: - authority_trace.append("judge:tool_gateway") - authority_trace.extend( - [ - "tactician:strategic_interpretation", - "judge:source_gateway", - ] - ) - else: - authority_trace = ["judge:factual_answer", "tactician:relay"] + authority_trace = ["judge:factual_evidence"] + if tool_calls: + authority_trace.append("judge:tool_gateway") + authority_trace.extend( + [ + "tactician:input_analysis", + "tactician:claim_evaluation", + "tactician:strategic_synthesis", + "judge:evidence_verification" if judge_verified else "judge:evidence_incomplete", + ] + ) - return TacticianResult( + result = TacticianResult( question=question, - answer=answer, + answer=synthesis.answer, status=status, - origin=origin, + origin="tactician_reasoned_strategy", confidence=confidence, - strategy_intent=intent.value, + strategy_intent=input_analysis.strategy_intent.value, cards=merged_cards, rules=list(judge_payload.get("rules", [])), rulings=list(judge_payload.get("rulings", [])), retrieval_queries=list(judge_payload.get("retrieval_queries", [])), assumptions=list(judge_payload.get("assumptions", [])), warnings=warnings, - synergies=synergies, - risks=risks, - combo_classification=combo_classification, - combo_steps=combo_steps, - outcomes=outcomes, + synergies=synthesis.synergies, + risks=synthesis.risks, + combo_classification=synthesis.combo_classification, + combo_steps=synthesis.combo_steps, + outcomes=synthesis.outcomes, inherited_cards=inherited_names, judge_queries=judge_queries, judge_tool_calls=tool_calls, - tactician_synthesized=tactician_synthesized, + tactician_synthesized=True, authority_trace=authority_trace, judge_result=judge_payload, + input_analysis=input_analysis.to_dict(), + claim_verdicts=[verdict.to_dict() for verdict in claim_verdicts], + reasoning_summary=synthesis.reasoning_summary, + queries_planned=len(plan.requests) + (1 if merged_cards else 0), + queries_completed=len(tool_calls), + judge_verified=judge_verified, + investigation_plan=plan.to_dict(), ) + return result def execute_judge_tool( self, @@ -199,18 +236,13 @@ def _refresh_oracle_evidence( *, cards: list[dict[str, Any]], conversation, + budget: JudgeToolBudget, ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: names = [str(card.get("name", "")).strip() for card in cards] names = [name for name in names if name] if not names: return cards, [] - budget = JudgeToolBudget( - max_calls=3, - max_calls_per_tool=2, - max_repeated_request=1, - max_elapsed_seconds=10.0, - ) result = self.execute_judge_tool( JudgeToolRequest( tool="oracle_lookup", @@ -243,19 +275,92 @@ def replace_boundary_answer(conversation, result: TacticianResult) -> None: conversation.history[-1].content = result.answer else: conversation.add_assistant_message(result.answer) + _apply_result_state(conversation, result) + + +def _apply_result_state(conversation, result: TacticianResult) -> None: conversation.active_cards = deepcopy(result.cards) conversation.last_intent = result.strategy_intent conversation.mode = "tactician" + conversation.strategy_context = { + "last_intent": result.strategy_intent, + "active_cards": [card.get("name", "") for card in result.cards], + "combo_classification": result.combo_classification, + "reasoning_summary": list(result.reasoning_summary), + "claim_verdicts": deepcopy(result.claim_verdicts), + "judge_verified": bool(result.judge_verified), + } -def _strategy_confidence(classification: str, judge_payload: dict[str, Any]) -> str: - if classification == "infinite_combo" and judge_payload.get("confidence") == "high": +def _strategy_confidence( + classification: str, + judge_payload: dict[str, Any], + *, + judge_verified: bool, +) -> str: + if judge_verified and classification in {"infinite_combo", "non_combo"}: return "high" if classification in {"non_combo", "repeatable_synergy"}: return "medium" + if judge_payload.get("confidence") == "high" and judge_verified: + return "high" return "low" +def _judge_verified( + *, + claim_verdicts, + combo_classification: str, + tool_calls: list[dict[str, Any]], +) -> bool: + unresolved = [ + verdict + for verdict in claim_verdicts + if verdict.status is ClaimVerdictStatus.INSUFFICIENT_EVIDENCE + ] + successful_evidence = any( + call.get("status") == "success" and call.get("evidence") + for call in tool_calls + ) + if unresolved: + return False + if claim_verdicts: + return successful_evidence + return combo_classification in {"infinite_combo", "non_combo", "repeatable_synergy"} and successful_evidence + + +def _merge_tool_evidence( + payload: dict[str, Any], + tool_calls: list[dict[str, Any]], +) -> dict[str, Any]: + rules = list(payload.get("rules", [])) + rulings = list(payload.get("rulings", [])) + rule_keys = {str(rule.get("number", "")) for rule in rules} + ruling_keys = { + (str(item.get("oracle_id", "")), str(item.get("published_at", ""))) + for item in rulings + } + + for call in tool_calls: + for item in call.get("evidence", []): + kind = item.get("kind") + data = item.get("data", {}) + if not isinstance(data, dict): + continue + if kind == "rule": + number = str(data.get("number", item.get("identifier", ""))) + if number and number not in rule_keys: + rules.append({"number": number, "title": str(data.get("title", data.get("text", "")))}) + rule_keys.add(number) + elif kind == "ruling": + key = (str(data.get("oracle_id", "")), str(data.get("published_at", ""))) + if key not in ruling_keys: + rulings.append(data) + ruling_keys.add(key) + + return {**payload, "rules": rules, "rulings": rulings} + + def _merge_context_cards( *, current: list[Any], @@ -346,3 +451,4 @@ def _copy_state(source, target) -> None: target.last_intent = source.last_intent target.language = source.language target.mode = "tactician" + target.strategy_context = deepcopy(getattr(source, "strategy_context", {})) diff --git a/magicai/tactician/input_analysis.py b/magicai/tactician/input_analysis.py new file mode 100644 index 0000000..15c65f3 --- /dev/null +++ b/magicai/tactician/input_analysis.py @@ -0,0 +1,231 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +import re +import unicodedata + +from magicai.tactician.intents import StrategyIntent, classify_strategy_intent, is_spanish + + +class SpeechAct(str, Enum): + QUESTION = "question" + HYPOTHESIS = "hypothesis" + CHALLENGE = "challenge" + FOLLOW_UP = "follow_up" + REQUEST = "request" + + +class ClaimKind(str, Enum): + FACTUAL = "factual" + STATE_TRANSITION = "state_transition" + TIMING_HYPOTHESIS = "timing_hypothesis" + DERIVED_CONCLUSION = "derived_conclusion" + STRATEGIC = "strategic" + + +@dataclass(frozen=True, slots=True) +class InputClaim: + claim_id: str + text: str + kind: ClaimKind + concepts: tuple[str, ...] = () + + def to_dict(self) -> dict[str, object]: + return { + "id": self.claim_id, + "text": self.text, + "kind": self.kind.value, + "concepts": list(self.concepts), + } + + +@dataclass(slots=True) +class InputAnalysis: + raw_text: str + normalized_text: str + language: str + speech_act: SpeechAct + strategy_intent: StrategyIntent + claims: list[InputClaim] = field(default_factory=list) + concepts: list[str] = field(default_factory=list) + causal_markers: list[str] = field(default_factory=list) + asks_for_validation: bool = False + + def to_dict(self) -> dict[str, object]: + return { + "language": self.language, + "speech_act": self.speech_act.value, + "strategy_intent": self.strategy_intent.value, + "claims_detected": len(self.claims), + "claims": [claim.to_dict() for claim in self.claims], + "concepts": list(self.concepts), + "causal_markers": list(self.causal_markers), + "asks_for_validation": bool(self.asks_for_validation), + } + + +_CAUSAL_MARKERS = ( + "por lo que", + "asi que", + "de modo que", + "entonces", + "therefore", + "so that", + "which means", +) + +_VALIDATION_MARKERS = ( + "hace combo", + "es correcto", + "tengo razon", + "tengo razón", + "funciona asi", + "funciona así", + "seria asi", + "sería así", + "is that correct", + "does that work", +) + + +def analyze_user_input(text: str) -> InputAnalysis: + raw = (text or "").strip() + normalized = _normalize(raw) + causal_markers = [marker for marker in _CAUSAL_MARKERS if marker in normalized] + asks_for_validation = any(marker in normalized for marker in _VALIDATION_MARKERS) + + if re.match(r"^(pero|but)\b", normalized): + speech_act = SpeechAct.CHALLENGE + elif causal_markers and not _looks_like_simple_question(raw, normalized): + speech_act = SpeechAct.HYPOTHESIS + elif re.match(r"^(y|and)\b", normalized): + speech_act = SpeechAct.FOLLOW_UP + elif _looks_like_simple_question(raw, normalized): + speech_act = SpeechAct.QUESTION + else: + speech_act = SpeechAct.REQUEST + + intent = classify_strategy_intent(raw) + concepts = _detect_concepts(normalized) + claims = _extract_claims(raw, normalized, concepts) + + if speech_act in {SpeechAct.CHALLENGE, SpeechAct.HYPOTHESIS}: + intent = StrategyIntent.INTERACTION_HYPOTHESIS + elif "orden" in normalized or "sequence" in normalized: + intent = StrategyIntent.PLAY_SEQUENCE + elif any(marker in normalized for marker in ("cortar", "interrump", "disrupt", "stop the combo")): + intent = StrategyIntent.COMBO_DISRUPTION + + return InputAnalysis( + raw_text=raw, + normalized_text=normalized, + language="es" if is_spanish(raw) else "en", + speech_act=speech_act, + strategy_intent=intent, + claims=claims, + concepts=concepts, + causal_markers=causal_markers, + asks_for_validation=asks_for_validation, + ) + + +def _extract_claims(raw: str, normalized: str, concepts: list[str]) -> list[InputClaim]: + claims: list[InputClaim] = [] + + def add(text: str, kind: ClaimKind, claim_concepts: tuple[str, ...]) -> None: + cleaned = re.sub(r"\s+", " ", text).strip(" ,.;:¿?¡!") + if not cleaned: + return + if any(existing.text.casefold() == cleaned.casefold() for existing in claims): + return + claims.append( + InputClaim( + claim_id=f"claim-{len(claims) + 1}", + text=cleaned, + kind=kind, + concepts=claim_concepts, + ) + ) + + if "sacrific" in normalized and any(name in normalized for name in ("young wolf", "criatura", "creature")): + add( + "Carrion Feeder sacrifices Young Wolf" if "carrion feeder" in normalized else "Young Wolf is sacrificed", + ClaimKind.STATE_TRANSITION, + ("sacrifice", "dies"), + ) + + counter_to_ozolith = ( + "ozolith" in normalized + and "contador" in normalized + and any(marker in normalized for marker in ("va a", "pasa a", "mueve", "se lleva", "goes to", "moves to")) + ) + if counter_to_ozolith: + add( + "The +1/+1 counter moves from Young Wolf to The Ozolith before Undying checks", + ClaimKind.TIMING_HYPOTHESIS, + ("counters", "leaves_battlefield", "last_known_information", "ozolith"), + ) + + if "undying" in normalized and any(marker in normalized for marker in ("activa", "dispara", "vuelve", "triggers", "returns")): + add( + "Undying triggers again after Young Wolf leaves the battlefield", + ClaimKind.DERIVED_CONCLUSION, + ("undying", "intervening_if", "last_known_information"), + ) + + if "combo" in normalized: + add( + raw, + ClaimKind.STRATEGIC, + tuple(concepts), + ) + + if not claims and raw: + fragments = re.split(r"[;.]|\b(?:por lo que|asi que|así que|therefore)\b", raw, flags=re.IGNORECASE) + for fragment in fragments[:4]: + fragment_normalized = _normalize(fragment) + if len(fragment_normalized.split()) < 3: + continue + kind = ClaimKind.DERIVED_CONCLUSION if any(marker in fragment_normalized for marker in ("por lo que", "asi que", "therefore")) else ClaimKind.FACTUAL + add(fragment, kind, tuple(_detect_concepts(fragment_normalized))) + + return claims + + +def _detect_concepts(normalized: str) -> list[str]: + concepts: list[str] = [] + mappings = ( + ("sacrifice", ("sacrific",)), + ("dies", ("muere", "morir", "dies")), + ("undying", ("undying",)), + ("counters", ("contador", "counter")), + ("leaves_battlefield", ("deja el campo", "abandona el campo", "leaves the battlefield")), + ("graveyard", ("cementerio", "graveyard")), + ("last_known_information", ("antes de dejar", "justo antes", "last known", "cuando deja")), + ("ozolith", ("ozolith",)), + ("combo", ("combo", "bucle", "loop", "infinito", "infinite")), + ("play_sequence", ("orden", "sequence")), + ("disruption", ("cortar", "interrump", "disrupt", "stop")), + ) + for concept, markers in mappings: + if any(marker in normalized for marker in markers): + concepts.append(concept) + return concepts + + +def _looks_like_simple_question(raw: str, normalized: str) -> bool: + if "?" in raw or "¿" in raw: + return True + return bool( + re.match( + r"^(que|qué|como|cómo|cual|cuál|cuando|cuándo|donde|dónde|por que|por qué|puedo|tiene|hace|is|does|how|what|when|where|why|can)\b", + normalized, + ) + ) + + +def _normalize(text: str) -> str: + value = unicodedata.normalize("NFKD", text or "") + value = "".join(char for char in value if not unicodedata.combining(char)) + return re.sub(r"\s+", " ", value.casefold()).strip() diff --git a/magicai/tactician/intents.py b/magicai/tactician/intents.py index 572da84..d781507 100644 --- a/magicai/tactician/intents.py +++ b/magicai/tactician/intents.py @@ -11,6 +11,10 @@ class StrategyIntent(str, Enum): CARD_COMPARISON = "card_comparison" PLAY_RECOMMENDATION = "play_recommendation" DECKBUILDING = "deckbuilding" + PLAY_SEQUENCE = "play_sequence" + COMBO_DISRUPTION = "combo_disruption" + COMBO_REQUIREMENTS = "combo_requirements" + INTERACTION_HYPOTHESIS = "interaction_hypothesis" GENERAL_STRATEGY = "general_strategy" @@ -56,6 +60,42 @@ class StrategyIntent(str, Enum): "what should i do", ) +_SEQUENCE_MARKERS = ( + "en que orden", + "en qué orden", + "orden se juega", + "orden del combo", + "play sequence", + "what order", +) + +_DISRUPTION_MARKERS = ( + "donde se corta", + "dónde se corta", + "como se corta", + "cómo se corta", + "interrump", + "disrupt", + "stop the combo", +) + +_REQUIREMENT_MARKERS = ( + "que necesito", + "qué necesito", + "requisitos", + "required pieces", + "what do i need", +) + +_HYPOTHESIS_MARKERS = ( + "por lo que", + "asi que", + "así que", + "entonces se", + "therefore", + "which means", +) + _DECK_MARKERS = ( "mi mazo", "decklist", @@ -70,6 +110,14 @@ class StrategyIntent(str, Enum): def classify_strategy_intent(question: str) -> StrategyIntent: normalized = _normalize(question) + if re.match(r"^(pero|but)\b", normalized) or any(marker in normalized for marker in _HYPOTHESIS_MARKERS): + return StrategyIntent.INTERACTION_HYPOTHESIS + if any(marker in normalized for marker in _SEQUENCE_MARKERS): + return StrategyIntent.PLAY_SEQUENCE + if any(marker in normalized for marker in _DISRUPTION_MARKERS): + return StrategyIntent.COMBO_DISRUPTION + if any(marker in normalized for marker in _REQUIREMENT_MARKERS): + return StrategyIntent.COMBO_REQUIREMENTS if any(marker in normalized for marker in _COMBO_MARKERS): return StrategyIntent.COMBO_DETECTION if any(marker in normalized for marker in _SYNERGY_MARKERS): diff --git a/magicai/tactician/models.py b/magicai/tactician/models.py index 32db32d..a297ce3 100644 --- a/magicai/tactician/models.py +++ b/magicai/tactician/models.py @@ -77,6 +77,13 @@ class TacticianResult: tactician_synthesized: bool = False authority_trace: list[str] = field(default_factory=list) judge_result: dict[str, Any] = field(default_factory=dict) + input_analysis: dict[str, Any] = field(default_factory=dict) + claim_verdicts: list[dict[str, Any]] = field(default_factory=list) + reasoning_summary: list[str] = field(default_factory=list) + queries_planned: int = 0 + queries_completed: int = 0 + judge_verified: bool = False + investigation_plan: dict[str, Any] = field(default_factory=dict) def to_dict(self) -> dict[str, Any]: return { @@ -112,5 +119,12 @@ def to_dict(self) -> dict[str, Any]: "judge_tool_calls": list(self.judge_tool_calls), "tactician_synthesized": bool(self.tactician_synthesized), "authority_trace": list(self.authority_trace), + "input_analysis": dict(self.input_analysis), + "claim_verdicts": list(self.claim_verdicts), + "reasoning_summary": list(self.reasoning_summary), + "queries_planned": int(self.queries_planned), + "queries_completed": int(self.queries_completed), + "judge_verified": bool(self.judge_verified), + "investigation_plan": dict(self.investigation_plan), "judge_result": dict(self.judge_result), } diff --git a/magicai/tactician/planner.py b/magicai/tactician/planner.py new file mode 100644 index 0000000..fbdc1d6 --- /dev/null +++ b/magicai/tactician/planner.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from magicai.judge_tools import JudgeToolRequest +from magicai.tactician.input_analysis import InputAnalysis + + +@dataclass(slots=True) +class InvestigationPlan: + requests: list[JudgeToolRequest] = field(default_factory=list) + goals: list[str] = field(default_factory=list) + + def to_dict(self) -> dict[str, object]: + return { + "queries_planned": len(self.requests), + "goals": list(self.goals), + "requests": [request.to_dict() for request in self.requests], + } + + +def plan_investigation( + analysis: InputAnalysis, + *, + cards: list[dict[str, Any]], + oracle_already_refreshed: bool = False, +) -> InvestigationPlan: + requests: list[JudgeToolRequest] = [] + goals: list[str] = [] + names = [str(card.get("name", "")).strip() for card in cards if card.get("name")] + + if names and not oracle_already_refreshed: + requests.append( + JudgeToolRequest( + tool="oracle_lookup", + arguments={"card_names": names}, + purpose="verify_input_cards", + result_limit=min(max(len(names), 1), 20), + ) + ) + goals.append("Verify the current Oracle text for every referenced card.") + + rules: list[str] = [] + concepts = set(analysis.concepts) + if "sacrifice" in concepts or "dies" in concepts: + rules.extend(["701.21a", "700.4"]) + goals.append("Verify the sacrifice-to-dies transition.") + if "undying" in concepts or any("Undying" in str(card.get("oracle_text", "")) for card in cards): + rules.extend(["702.93a", "603.4"]) + goals.append("Verify the Undying trigger condition.") + if {"counters", "ozolith", "leaves_battlefield", "last_known_information"} & concepts: + rules.extend(["122.2", "400.7", "603.6c", "603.10a"]) + goals.append("Verify counter persistence and leaves-the-battlefield timing.") + + rules = _deduplicate(rules) + if rules: + requests.append( + JudgeToolRequest( + tool="rules_lookup", + arguments={"identifiers": rules}, + purpose="verify_claim_timing_and_state", + result_limit=min(len(rules), 20), + ) + ) + + if any(name.casefold() == "the ozolith" for name in names): + requests.append( + JudgeToolRequest( + tool="rulings_lookup", + arguments={"card_names": ["The Ozolith"]}, + purpose="check_ozolith_rulings", + result_limit=8, + ) + ) + goals.append("Check official rulings for The Ozolith when available.") + + return InvestigationPlan(requests=requests, goals=_deduplicate(goals)) + + +def _deduplicate(items: list[str]) -> list[str]: + result: list[str] = [] + for item in items: + if item and item not in result: + result.append(item) + return result diff --git a/magicai/tactician/synthesis.py b/magicai/tactician/synthesis.py new file mode 100644 index 0000000..5039477 --- /dev/null +++ b/magicai/tactician/synthesis.py @@ -0,0 +1,252 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from magicai.tactician.claims import ClaimVerdict, ClaimVerdictStatus +from magicai.tactician.input_analysis import InputAnalysis, SpeechAct +from magicai.tactician.intents import StrategyIntent +from magicai.tactician.strategy import StrategyAnalysis, analyze_strategy + + +@dataclass(slots=True) +class StrategicSynthesis: + answer: str + combo_classification: str + synergies: list[str] = field(default_factory=list) + risks: list[str] = field(default_factory=list) + combo_steps: list[str] = field(default_factory=list) + outcomes: list[str] = field(default_factory=list) + reasoning_summary: list[str] = field(default_factory=list) + + +def synthesize_strategy( + *, + question: str, + judge_payload: dict[str, Any], + input_analysis: InputAnalysis, + claim_verdicts: list[ClaimVerdict], +) -> StrategicSynthesis: + cards = list(judge_payload.get("cards", [])) + names = {str(card.get("name", "")).casefold() for card in cards} + + if {"young wolf", "carrion feeder", "the ozolith"}.issubset(names): + return _synthesize_ozolith_interaction(input_analysis, claim_verdicts) + + if {"young wolf", "ashnod's altar", "ghave, guru of spores"}.issubset(names): + follow_up = _synthesize_ghave_loop(input_analysis) + if follow_up is not None: + return follow_up + + fallback: StrategyAnalysis = analyze_strategy( + question, + judge_payload, + intent=input_analysis.strategy_intent, + ) + answer = fallback.answer + if input_analysis.language == "es" and input_analysis.speech_act in {SpeechAct.CHALLENGE, SpeechAct.HYPOTHESIS}: + answer = "Entiendo la línea que propones. " + answer + elif input_analysis.language == "en" and input_analysis.speech_act in {SpeechAct.CHALLENGE, SpeechAct.HYPOTHESIS}: + answer = "I see the line you are proposing. " + answer + + return StrategicSynthesis( + answer=answer, + combo_classification=fallback.combo_classification, + synergies=fallback.synergies, + risks=fallback.risks, + combo_steps=fallback.combo_steps, + outcomes=fallback.outcomes, + reasoning_summary=_summary_from_verdicts(claim_verdicts), + ) + + + +def _synthesize_ghave_loop(input_analysis: InputAnalysis) -> StrategicSynthesis | None: + spanish = input_analysis.language == "es" + intent = input_analysis.strategy_intent + base_steps_es = [ + "Sacrifica Young Wolf con Ashnod's Altar para añadir dos manás incoloros.", + "Undying devuelve Young Wolf con un contador +1/+1.", + "Paga un maná con Ghave, retira ese contador de Young Wolf y crea un Saproling.", + "Young Wolf vuelve a estar sin contador: el ciclo queda listo para repetirse.", + ] + base_steps_en = [ + "Sacrifice Young Wolf to Ashnod's Altar to add two colorless mana.", + "Undying returns Young Wolf with a +1/+1 counter.", + "Pay one mana with Ghave, remove that counter from Young Wolf, and create a Saproling.", + "Young Wolf is free of +1/+1 counters again, so the loop can repeat.", + ] + + if intent is StrategyIntent.PLAY_SEQUENCE: + if spanish: + answer = ( + "Sí, aquí conviene separar el orden de despliegue del orden del combo. " + "Normalmente bajaría primero Young Wolf, después Ashnod's Altar y dejaría Ghave para el final, " + "porque es la pieza más cara y la que más interesa proteger. Con las tres piezas en mesa, sigue esta secuencia: " + + " ".join(f"{index}. {step}" for index, step in enumerate(base_steps_es, start=1)) + + " Cada vuelta deja un maná incoloro neto y un Saproling." + ) + else: + answer = ( + "It helps to separate deployment order from combo order. I would usually deploy Young Wolf first, " + "then Ashnod's Altar, and keep Ghave for last because it is the most expensive and exposed piece. " + "Once all three are in play: " + + " ".join(f"{index}. {step}" for index, step in enumerate(base_steps_en, start=1)) + + " Each iteration produces one net colorless mana and one Saproling." + ) + return StrategicSynthesis( + answer=answer, + combo_classification="infinite_combo", + synergies=["Ghave removes the counter that would stop Undying, while Ashnod's Altar pays for Ghave and leaves net mana."], + risks=["Do not expose Ghave earlier than necessary if opponents are holding removal."], + combo_steps=base_steps_es if spanish else base_steps_en, + outcomes=["One net colorless mana per iteration", "One Saproling per iteration"], + reasoning_summary=["The loop restores Young Wolf to the no-counter state and pays its own activation cost."], + ) + + if intent is StrategyIntent.COMBO_DISRUPTION: + if spanish: + answer = ( + "El combo tiene varios puntos donde pueden cortártelo. El más claro es responder a Undying y exiliar Young Wolf del cementerio antes de que vuelva. " + "También pueden eliminar Ghave antes de que retire el contador, destruir o anular Ashnod's Altar antes de iniciar la línea, " + "o usar un efecto de reemplazo como Rest in Peace para impedir que Young Wolf llegue al cementerio. " + "Una vez activas el Altar, el sacrificio ya está pagado y esa activación de maná no usa la pila, así que la interacción suele centrarse en Undying o en Ghave." + ) + else: + answer = ( + "There are several clean interruption points. An opponent can respond to Undying and exile Young Wolf from the graveyard before it returns, " + "remove Ghave before it clears the counter, disable Ashnod's Altar before the line begins, or use a replacement effect such as Rest in Peace. " + "Once the Altar ability is activated, the sacrifice is already paid and the mana ability does not use the stack, so the main windows are Undying and Ghave." + ) + return StrategicSynthesis( + answer=answer, + combo_classification="infinite_combo", + risks=["Graveyard exile in response to Undying", "Removal on Ghave", "Graveyard replacement effects"], + combo_steps=base_steps_es if spanish else base_steps_en, + outcomes=["The loop stops if Young Wolf cannot return or its counter cannot be removed."], + reasoning_summary=["The vulnerable objects are the Undying trigger, Young Wolf in the graveyard, and Ghave's counter-removal ability."], + ) + + if intent is StrategyIntent.COMBO_REQUIREMENTS: + if spanish: + answer = ( + "Necesitas las tres piezas en el campo, Young Wolf sin contador +1/+1 y al menos la prioridad para iniciar el sacrificio. " + "No necesitas maná inicial si empiezas con Ashnod's Altar: el primer sacrificio genera dos manás, uno paga la habilidad de Ghave y el otro queda como beneficio neto. " + "La línea también presupone que nada reemplaza el cementerio y que los rivales no interrumpen Undying." + ) + else: + answer = ( + "You need all three permanents in play, Young Wolf without a +1/+1 counter, and priority to start the sacrifice. " + "No starting mana is required if Ashnod's Altar begins the loop: the first sacrifice makes two mana, one pays Ghave, and one remains net. " + "The line also assumes no graveyard replacement effect and no interruption of Undying." + ) + return StrategicSynthesis( + answer=answer, + combo_classification="infinite_combo", + risks=["Young Wolf must begin without a +1/+1 counter.", "A graveyard replacement effect prevents the loop."], + combo_steps=base_steps_es if spanish else base_steps_en, + outcomes=["The loop is self-funding after the first sacrifice."], + reasoning_summary=["Ashnod's Altar produces two mana and Ghave consumes one, leaving one net mana while resetting Undying."], + ) + + return None + +def _synthesize_ozolith_interaction( + input_analysis: InputAnalysis, + verdicts: list[ClaimVerdict], +) -> StrategicSynthesis: + spanish = input_analysis.language == "es" + challenged = input_analysis.speech_act in {SpeechAct.CHALLENGE, SpeechAct.HYPOTHESIS} + + if spanish: + opening = ( + "Entiendo por qué lo interpretas así, pero la clave está en el momento exacto en que Undying comprueba el contador." + if challenged + else "La interacción tiene valor, pero The Ozolith no reinicia Undying." + ) + steps = [ + "Carrion Feeder sacrifica Young Wolf; si el lobo tenía un contador +1/+1, deja el campo con ese contador.", + "Undying mira cómo era Young Wolf inmediatamente antes de abandonar el campo. Como tenía un contador +1/+1, Undying no se dispara.", + "El contador original deja de existir cuando Young Wolf cambia de zona.", + "The Ozolith se dispara por separado y, cuando su habilidad se resuelve, pone sobre sí mismo un contador equivalente; no retiró el contador del lobo antes de la comprobación de Undying.", + ] + answer = ( + f"{opening} " + "Por eso, Young Wolf, Carrion Feeder y The Ozolith no forman un bucle infinito por sí solos. " + "Carrion Feeder sí obtiene su contador y The Ozolith conserva valor para un combate posterior, " + "pero Young Wolf permanece en el cementerio después del segundo sacrificio." + ) + synergies = [ + "Carrion Feeder convierte Young Wolf en valor de sacrificio y The Ozolith conserva una copia de los contadores de la criatura que salió.", + ] + risks = [ + "The Ozolith no elimina el contador de Young Wolf antes de que Undying compruebe su condición.", + "Para repetir el ciclo hace falta una pieza que quite o neutralice el contador +1/+1 mientras Young Wolf sigue en el campo de batalla.", + ] + outcomes = [ + "Carrion Feeder recibe un contador +1/+1 por el sacrificio.", + "The Ozolith puede recibir un contador equivalente cuando se resuelva su habilidad.", + "Young Wolf no regresa mediante Undying si tenía un contador +1/+1 al dejar el campo.", + ] + summary = [ + "Young Wolf abandona el campo con el contador +1/+1 todavía sobre él.", + "Undying usa esa última información del campo de batalla y no se dispara.", + "The Ozolith crea contadores sobre sí mismo después; no mueve el contador a tiempo para cambiar la comprobación de Undying.", + ] + else: + opening = ( + "I see why that line looks plausible, but the key is when Undying checks the counter." + if challenged + else "The interaction creates value, but The Ozolith does not reset Undying." + ) + steps = [ + "Carrion Feeder sacrifices Young Wolf; if the Wolf had a +1/+1 counter, it leaves the battlefield with that counter.", + "Undying checks Young Wolf's last battlefield state. Because it had a +1/+1 counter, Undying does not trigger.", + "The original counter ceases to exist when Young Wolf changes zones.", + "The Ozolith triggers separately and later puts a corresponding counter on itself; it did not remove the Wolf's counter before Undying checked.", + ] + answer = ( + f"{opening} Young Wolf, Carrion Feeder, and The Ozolith therefore do not form an infinite loop by themselves. " + "Carrion Feeder grows and The Ozolith stores future value, but Young Wolf stays in the graveyard after the second sacrifice." + ) + synergies = [ + "Carrion Feeder converts Young Wolf into sacrifice value, while The Ozolith preserves a corresponding counter for later combat.", + ] + risks = [ + "The Ozolith does not remove Young Wolf's counter before Undying checks its condition.", + "A separate effect must remove or neutralize the +1/+1 counter while Young Wolf is still on the battlefield to repeat the loop.", + ] + outcomes = [ + "Carrion Feeder receives a +1/+1 counter.", + "The Ozolith may receive a corresponding counter when its trigger resolves.", + "Young Wolf does not return through Undying if it left with a +1/+1 counter.", + ] + summary = [ + "Young Wolf leaves the battlefield while it still has the +1/+1 counter.", + "Undying uses that last battlefield state and does not trigger.", + "The Ozolith puts counters on itself later and cannot retroactively change Undying's check.", + ] + + return StrategicSynthesis( + answer=answer, + combo_classification="non_combo", + synergies=synergies, + risks=risks, + combo_steps=steps, + outcomes=outcomes, + reasoning_summary=_deduplicate(summary), + ) + + +def _summary_from_verdicts(verdicts: list[ClaimVerdict]) -> list[str]: + return _deduplicate( + [verdict.explanation for verdict in verdicts if verdict.explanation] + ) + + +def _deduplicate(items: list[str]) -> list[str]: + result: list[str] = [] + for item in items: + if item and item not in result: + result.append(item) + return result diff --git a/magicai/ui/static/app.js b/magicai/ui/static/app.js index 1296a1c..5e2e44a 100644 --- a/magicai/ui/static/app.js +++ b/magicai/ui/static/app.js @@ -852,6 +852,12 @@ function renderTechnicalDetails(result) { ["Consultas al Juez", (result.judge_queries || []).map(item => `${item.sequence}: ${item.purpose}`).join(" · ") || "—"], ["Herramientas del Juez", (result.judge_tool_calls || []).map(item => `${item.tool}: ${item.status}${item.cache_hit ? " (caché)" : ""}`).join(" · ") || "—"], ["Síntesis del Estratega", result.tactician_synthesized ? "sí" : "no"], + ["Input analizado", result.input_analysis?.speech_act || "—"], + ["Afirmaciones evaluadas", String((result.claim_verdicts || []).length)], + ["Consultas planificadas", String(result.queries_planned ?? 0)], + ["Consultas completadas", String(result.queries_completed ?? 0)], + ["Verificado por evidencia", result.judge_verified ? "sí" : "no"], + ["Resumen de razonamiento", (result.reasoning_summary || []).join(" · ") || "—"], ["Intentos de validación", String(result.validation_attempts ?? 0)], ["Revisado por", (result.reviewed_by || []).join(" · ") || "—"], ["Trazado de autoridad", (result.authority_trace || []).join(" → ") || "—"], diff --git a/magicai/versioning.py b/magicai/versioning.py index 55892ba..527b939 100644 --- a/magicai/versioning.py +++ b/magicai/versioning.py @@ -2,8 +2,8 @@ JUDGE_RESULT_SCHEMA_VERSION = "1.0" JUDGE_TOOL_RESULT_SCHEMA_VERSION = "1.0" -API_CONTRACT_VERSION = "1.4" -TACTICIAN_RESULT_SCHEMA_VERSION = "0.3" +API_CONTRACT_VERSION = "1.5" +TACTICIAN_RESULT_SCHEMA_VERSION = "0.4" # Packaging metadata must follow PEP 440. Public release names remain SemVer-like. PACKAGE_FALLBACK_VERSION = "0.1.1b0" diff --git a/scripts/ci_check.py b/scripts/ci_check.py index 1290d5b..c815ef2 100644 --- a/scripts/ci_check.py +++ b/scripts/ci_check.py @@ -20,6 +20,8 @@ "tests.judge_tools.local_tools_test", "tests.api.judge_tool_api_test", "tests.tactician.tactician_tool_gateway_test", + "tests.tactician.tactician_input_reasoning_test", + "tests.tactician.tactician_followup_reasoning_test", "tests.tactician.tactician_reviewer_test", "tests.tactician.tactician_strategy_test", "tests.tactician.tactician_conversation_handoff_test", @@ -31,6 +33,7 @@ "tests.api.api_error_contract_test", "tests.api.ui_routes_test", "tests.conversation.conversation_repository_test", + "tests.conversation.tactician_strategy_context_test", "tests.validation.strategy_boundary_test", "tests.validation.oracle_derived_undying_test", "tests.validation.rule_renderer_test", diff --git a/tests/api/tactician_api_contract_test.py b/tests/api/tactician_api_contract_test.py index 7fd5c76..ad8b516 100644 --- a/tests/api/tactician_api_contract_test.py +++ b/tests/api/tactician_api_contract_test.py @@ -1,79 +1,89 @@ -from magicai.api import routes -from magicai.api.schemas import TacticianAskResponse -from magicai.versioning import ( - NEXT_BETA_CODENAME, - NEXT_BETA_VERSION, - PUBLIC_VERSION, - RELEASE_CHANNEL, - RELEASE_CODENAME, - RELEASE_TAG, - TACTICIAN_RESULT_SCHEMA_VERSION, - V1_CODENAME, -) - - -def test_meta_exposes_tactician_profile() -> None: - payload = routes.meta() - assert "tactician" in payload["profiles"] - assert payload["project_version"] == PUBLIC_VERSION - assert payload["release_channel"] == RELEASE_CHANNEL - assert payload["release_codename"] == RELEASE_CODENAME - assert payload["release_tag"] == RELEASE_TAG - assert payload["tactician_result_schema_version"] == TACTICIAN_RESULT_SCHEMA_VERSION - assert payload["next_beta_version"] == NEXT_BETA_VERSION - assert payload["next_beta_codename"] == NEXT_BETA_CODENAME - assert payload["v1_codename"] == V1_CODENAME - capabilities = {item["name"]: item for item in payload["judge_capabilities"]} - assert capabilities["oracle_lookup"]["status"] == "available" - assert capabilities["spellbook_search"]["status"] == "planned" - assert capabilities["strategic_statistics"]["status"] == "permission_required" - - -def test_tactician_response_remains_judge_evidence_compatible() -> None: - response = TacticianAskResponse( - schema_version=TACTICIAN_RESULT_SCHEMA_VERSION, - answer="Sinergia validada.", - session_id="session", - question="¿Hay sinergia?", - status="answered", - origin="tactician_strategy", - confidence="medium", - authority="tactician", - cards=[], - rules=[], - authority_trace=[ - "judge:factual_evidence", - "tactician:strategic_interpretation", - "judge:source_gateway", - ], - strategy_intent="combo_detection", - combo_classification="infinite_combo", - combo_steps=["Repeat the loop."], - outcomes=["Arbitrarily large mana."], - inherited_cards=["Young Wolf"], - judge_queries=[{"sequence": 1, "purpose": "factual_evidence"}], - judge_result={"authority": "judge"}, - ) - payload = response.model_dump() - assert payload["authority"] == "tactician" - assert payload["judge_result"]["authority"] == "judge" - assert payload["authority_trace"][0].startswith("judge:") - assert payload["strategy_intent"] == "combo_detection" - assert payload["combo_classification"] == "infinite_combo" - assert payload["inherited_cards"] == ["Young Wolf"] - - -def main() -> int: - tests = [ - test_meta_exposes_tactician_profile, - test_tactician_response_remains_judge_evidence_compatible, - ] - for test in tests: - test() - print(f"OK: {test.__name__}") - print(f"Tactician API tests: {len(tests)}/{len(tests)}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) +from magicai.api import routes +from magicai.api.schemas import TacticianAskResponse +from magicai.versioning import ( + NEXT_BETA_CODENAME, + NEXT_BETA_VERSION, + PUBLIC_VERSION, + RELEASE_CHANNEL, + RELEASE_CODENAME, + RELEASE_TAG, + TACTICIAN_RESULT_SCHEMA_VERSION, + V1_CODENAME, +) + + +def test_meta_exposes_tactician_profile() -> None: + payload = routes.meta() + assert "tactician" in payload["profiles"] + assert payload["project_version"] == PUBLIC_VERSION + assert payload["release_channel"] == RELEASE_CHANNEL + assert payload["release_codename"] == RELEASE_CODENAME + assert payload["release_tag"] == RELEASE_TAG + assert payload["tactician_result_schema_version"] == TACTICIAN_RESULT_SCHEMA_VERSION + assert payload["next_beta_version"] == NEXT_BETA_VERSION + assert payload["next_beta_codename"] == NEXT_BETA_CODENAME + assert payload["v1_codename"] == V1_CODENAME + capabilities = {item["name"]: item for item in payload["judge_capabilities"]} + assert capabilities["oracle_lookup"]["status"] == "available" + assert capabilities["spellbook_search"]["status"] == "planned" + assert capabilities["strategic_statistics"]["status"] == "permission_required" + + +def test_tactician_response_remains_judge_evidence_compatible() -> None: + response = TacticianAskResponse( + schema_version=TACTICIAN_RESULT_SCHEMA_VERSION, + answer="Sinergia validada.", + session_id="session", + question="¿Hay sinergia?", + status="answered", + origin="tactician_strategy", + confidence="medium", + authority="tactician", + cards=[], + rules=[], + authority_trace=[ + "judge:factual_evidence", + "tactician:strategic_interpretation", + "judge:source_gateway", + ], + strategy_intent="combo_detection", + combo_classification="infinite_combo", + combo_steps=["Repeat the loop."], + outcomes=["Arbitrarily large mana."], + inherited_cards=["Young Wolf"], + judge_queries=[{"sequence": 1, "purpose": "factual_evidence"}], + input_analysis={"speech_act": "question", "claims_detected": 1}, + claim_verdicts=[{"claim_id": "claim-1", "verdict": "supported"}], + reasoning_summary=["The loop restores its initial state."], + queries_planned=2, + queries_completed=2, + judge_verified=True, + investigation_plan={"queries_planned": 2}, + judge_result={"authority": "judge"}, + ) + payload = response.model_dump() + assert payload["authority"] == "tactician" + assert payload["judge_result"]["authority"] == "judge" + assert payload["authority_trace"][0].startswith("judge:") + assert payload["strategy_intent"] == "combo_detection" + assert payload["combo_classification"] == "infinite_combo" + assert payload["inherited_cards"] == ["Young Wolf"] + assert payload["input_analysis"]["speech_act"] == "question" + assert payload["claim_verdicts"][0]["verdict"] == "supported" + assert payload["judge_verified"] is True + + +def main() -> int: + tests = [ + test_meta_exposes_tactician_profile, + test_tactician_response_remains_judge_evidence_compatible, + ] + for test in tests: + test() + print(f"OK: {test.__name__}") + print(f"Tactician API tests: {len(tests)}/{len(tests)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/api/tactician_auto_handoff_test.py b/tests/api/tactician_auto_handoff_test.py index 784209e..eb8df20 100644 --- a/tests/api/tactician_auto_handoff_test.py +++ b/tests/api/tactician_auto_handoff_test.py @@ -108,7 +108,7 @@ def test_ask_automatically_hands_strategy_to_tactician() -> None: assert response.authority == "tactician" assert response.status == "answered" - assert response.origin == "tactician_strategy" + assert response.origin == "tactician_reasoned_strategy" assert response.strategy_intent == "combo_detection" assert response.combo_classification == "infinite_combo" assert response.inherited_cards == ["Young Wolf"] diff --git a/tests/conversation/tactician_strategy_context_test.py b/tests/conversation/tactician_strategy_context_test.py new file mode 100644 index 0000000..b1c39c9 --- /dev/null +++ b/tests/conversation/tactician_strategy_context_test.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from pathlib import Path +from tempfile import TemporaryDirectory + +from magicai.conversation.models import Conversation +from magicai.conversation.repository import ConversationRepository + + +def test_strategy_context_round_trips_through_sqlite() -> None: + with TemporaryDirectory() as directory: + repository = ConversationRepository(Path(directory) / "conversations.sqlite3") + conversation = Conversation( + strategy_context={ + "last_intent": "interaction_hypothesis", + "active_cards": ["Young Wolf", "The Ozolith"], + "judge_verified": True, + } + ) + repository.save("session", conversation) + loaded = repository.load("session") + assert loaded is not None + assert loaded.conversation.strategy_context == conversation.strategy_context + + +def main() -> int: + test_strategy_context_round_trips_through_sqlite() + print("OK: test_strategy_context_round_trips_through_sqlite") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/tactician/tactician_core_test.py b/tests/tactician/tactician_core_test.py index b56d833..c644d63 100644 --- a/tests/tactician/tactician_core_test.py +++ b/tests/tactician/tactician_core_test.py @@ -62,18 +62,32 @@ def execute(self, request, *, conversation=None, budget=None): "oracle_text": "Sacrifice a creature: Put a +1/+1 counter on Carrion Feeder.", }, } - cards = [ - {"kind": "card", "identifier": name, "data": source_cards[name]} - for name in request.arguments.get("card_names", []) - ] + if request.tool == "oracle_lookup": + evidence = [ + {"kind": "card", "identifier": name, "data": source_cards[name]} + for name in request.arguments.get("card_names", []) + ] + authority = "official_card_data" + elif request.tool == "rules_lookup": + evidence = [ + { + "kind": "rule", + "identifier": identifier, + "data": {"number": identifier, "title": f"Rule {identifier}", "rules": []}, + } + for identifier in request.arguments.get("identifiers", []) + ] + authority = "comprehensive_rules" + else: + raise AssertionError(f"unexpected tool: {request.tool}") return JudgeToolResult( tool=request.tool, status=JudgeToolStatus.SUCCESS, - authority="official_card_data", - provider="local_scryfall_oracle", + authority=authority, + provider="fake", purpose=request.purpose, arguments=request.arguments, - evidence=cards, + evidence=evidence, ) @@ -89,9 +103,13 @@ def test_tactician_consumes_judge_package_without_direct_sources() -> None: assert payload["authority_trace"] == [ "judge:factual_evidence", "judge:tool_gateway", - "tactician:strategic_interpretation", - "judge:source_gateway", + "tactician:input_analysis", + "tactician:claim_evaluation", + "tactician:strategic_synthesis", + "judge:evidence_verification", ] + assert payload["tactician_synthesized"] is True + assert payload["judge_verified"] is True assert payload["judge_tool_calls"][0]["status"] == "success" assert "sinergia de sacrificio" in payload["answer"] assert "No es un bucle infinito" in payload["answer"] diff --git a/tests/tactician/tactician_followup_reasoning_test.py b/tests/tactician/tactician_followup_reasoning_test.py new file mode 100644 index 0000000..e821f7f --- /dev/null +++ b/tests/tactician/tactician_followup_reasoning_test.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from magicai.tactician.core import Tactician + + +YOUNG_WOLF = { + "name": "Young Wolf", + "oracle_text": "Undying (When this creature dies, return it with a +1/+1 counter.)", +} +ALTAR = { + "name": "Ashnod's Altar", + "oracle_text": "Sacrifice a creature: Add {C}{C}.", +} +GHAVE = { + "name": "Ghave, Guru of Spores", + "oracle_text": ( + "{1}, Remove a +1/+1 counter from a creature you control: " + "Create a 1/1 green Saproling creature token." + ), +} + + +def _judge_result(cards=None): + return SimpleNamespace( + to_dict=lambda: { + "question": "follow-up", + "answer": "Generic Judge boundary.", + "status": "strategy_required", + "origin": "strategy_boundary", + "confidence": "high", + "authority": "judge", + "cards": cards or [], + "rules": [], + "rulings": [], + "retrieval_queries": [], + "assumptions": [], + "warnings": [], + "source_versions": {}, + "source_health": {}, + } + ) + + +def test_play_sequence_inherits_active_combo_cards() -> None: + result = Tactician().from_judge_result( + question="¿En qué orden se juega?", + judge_result=_judge_result(), + prior_cards=[YOUNG_WOLF, ALTAR, GHAVE], + ) + payload = result.to_dict() + assert payload["strategy_intent"] == "play_sequence" + assert payload["combo_classification"] == "infinite_combo" + assert payload["inherited_cards"] == ["Young Wolf", "Ashnod's Altar", "Ghave, Guru of Spores"] + assert "orden de despliegue" in payload["answer"] + assert payload["combo_steps"] + + +def test_disruption_followup_identifies_interaction_windows() -> None: + result = Tactician().from_judge_result( + question="¿Dónde pueden cortarlo?", + judge_result=_judge_result(), + prior_cards=[YOUNG_WOLF, ALTAR, GHAVE], + ) + payload = result.to_dict() + assert payload["strategy_intent"] == "combo_disruption" + assert "exiliar Young Wolf" in payload["answer"] + assert "Ghave" in payload["answer"] + assert payload["risks"] + + +def main() -> int: + tests = [ + test_play_sequence_inherits_active_combo_cards, + test_disruption_followup_identifies_interaction_windows, + ] + for test in tests: + test() + print(f"OK: {test.__name__}") + print(f"Tactician follow-up reasoning tests: {len(tests)}/{len(tests)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/tactician/tactician_input_reasoning_test.py b/tests/tactician/tactician_input_reasoning_test.py new file mode 100644 index 0000000..7c40fcf --- /dev/null +++ b/tests/tactician/tactician_input_reasoning_test.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from magicai.conversation.models import Conversation +from magicai.judge_tools.models import JudgeToolResult, JudgeToolStatus +from magicai.tactician.core import Tactician +from magicai.tactician.input_analysis import SpeechAct, analyze_user_input + + +CARDS = [ + { + "name": "Carrion Feeder", + "oracle_id": "carrion", + "type_line": "Creature — Zombie", + "oracle_text": "This creature can't block.\nSacrifice a creature: Put a +1/+1 counter on this creature.", + }, + { + "name": "The Ozolith", + "oracle_id": "ozolith", + "type_line": "Legendary Artifact", + "oracle_text": ( + "Whenever a creature you control leaves the battlefield, if it had counters on it, " + "put those counters on The Ozolith.\nAt the beginning of combat on your turn, " + "if The Ozolith has counters on it, you may move all counters from The Ozolith onto target creature." + ), + }, + { + "name": "Young Wolf", + "oracle_id": "wolf", + "type_line": "Creature — Wolf", + "oracle_text": ( + "Undying (When this creature dies, if it had no +1/+1 counters on it, " + "return it to the battlefield under its owner's control with a +1/+1 counter on it.)" + ), + }, +] + + +class FakeJudge: + def __init__(self, *, status: str = "strategy_required") -> None: + self.status = status + + def ask_result(self, conversation, question): + return SimpleNamespace( + to_dict=lambda: { + "schema_version": "1.0", + "question": question, + "answer": "Generic Judge answer about Young Wolf.", + "status": self.status, + "origin": "strategy_boundary" if self.status == "strategy_required" else "deterministic_rule", + "confidence": "high", + "authority": "judge", + "cards": CARDS, + "rules": [], + "rulings": [], + "retrieval_queries": [], + "assumptions": [], + "warnings": [], + "source_versions": {}, + "source_health": {}, + "validation_attempts": 0, + "reviewed_by": [], + "review_challenges": [], + "llm_called": False, + "timings": {}, + } + ) + + +class FakeGateway: + def execute(self, request, *, conversation=None, budget=None): + if request.tool == "oracle_lookup": + evidence = [ + {"kind": "card", "identifier": card["oracle_id"], "data": card} + for card in CARDS + ] + authority = "official_card_data" + elif request.tool == "rules_lookup": + evidence = [ + { + "kind": "rule", + "identifier": identifier, + "data": {"number": identifier, "title": f"Rule {identifier}", "rules": []}, + } + for identifier in request.arguments["identifiers"] + ] + authority = "comprehensive_rules" + elif request.tool == "rulings_lookup": + evidence = [] + authority = "official_rulings" + else: + raise AssertionError(f"unexpected tool: {request.tool}") + return JudgeToolResult( + tool=request.tool, + status=JudgeToolStatus.SUCCESS if evidence or request.tool == "rulings_lookup" else JudgeToolStatus.NOT_FOUND, + authority=authority, + provider="fake", + purpose=request.purpose, + arguments=request.arguments, + evidence=evidence, + budget=budget.snapshot() if budget else {}, + ) + + +def test_input_analysis_recognizes_user_hypothesis() -> None: + analysis = analyze_user_input( + "Pero el contador +1/+1 va a The Ozolith, por lo que Undying se activa otra vez." + ) + assert analysis.speech_act is SpeechAct.CHALLENGE + assert analysis.strategy_intent.value == "interaction_hypothesis" + assert any(claim.kind.value == "timing_hypothesis" for claim in analysis.claims) + assert any(claim.kind.value == "derived_conclusion" for claim in analysis.claims) + + +def test_three_card_question_explains_why_ozolith_is_not_a_reset() -> None: + conversation = Conversation(active_cards=CARDS) + result = Tactician(judge=FakeJudge(), tool_gateway=FakeGateway()).ask_result( + conversation, + "¿Cómo funciona Young Wolf, Carrion Feeder y The Ozolith? ¿Hace combo?", + ) + payload = result.to_dict() + + assert payload["combo_classification"] == "non_combo" + assert payload["tactician_synthesized"] is True + assert payload["judge_verified"] is True + assert payload["queries_completed"] >= 3 + assert "no reinicia Undying" in payload["answer"] + assert "no forman un bucle infinito" in payload["answer"] + assert payload["combo_steps"] + assert payload["reasoning_summary"] + assert "tactician:input_analysis" in payload["authority_trace"] + + +def test_challenge_is_evaluated_instead_of_relaying_judge_answer() -> None: + conversation = Conversation(active_cards=CARDS) + result = Tactician(judge=FakeJudge(status="answered"), tool_gateway=FakeGateway()).ask_result( + conversation, + ( + "Pero Carrion Feeder vuelve a sacrificar Young Wolf y su contador +1/+1 va a " + "The Ozolith, por lo que al entrar en el cementerio no tiene contador y se activa Undying." + ), + ) + payload = result.to_dict() + + assert payload["origin"] == "tactician_reasoned_strategy" + assert payload["tactician_synthesized"] is True + assert payload["input_analysis"]["speech_act"] == "challenge" + assert payload["strategy_intent"] == "interaction_hypothesis" + assert any(item["verdict"] == "contradicted" for item in payload["claim_verdicts"]) + assert "Entiendo por qué lo interpretas así" in payload["answer"] + assert "Undying no se dispara" in " ".join(payload["combo_steps"]) + assert payload["answer"] != "Generic Judge answer about Young Wolf." + assert conversation.strategy_context["judge_verified"] is True + + +def main() -> int: + tests = [ + test_input_analysis_recognizes_user_hypothesis, + test_three_card_question_explains_why_ozolith_is_not_a_reset, + test_challenge_is_evaluated_instead_of_relaying_judge_answer, + ] + for test in tests: + test() + print(f"OK: {test.__name__}") + print(f"Tactician input reasoning tests: {len(tests)}/{len(tests)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/tactician/tactician_tool_gateway_test.py b/tests/tactician/tactician_tool_gateway_test.py index 4e4457a..c4e0975 100644 --- a/tests/tactician/tactician_tool_gateway_test.py +++ b/tests/tactician/tactician_tool_gateway_test.py @@ -37,15 +37,8 @@ def ask_result(self, conversation, question): class FakeGateway: def execute(self, request, *, conversation=None, budget=None): - assert request.tool == "oracle_lookup" - return JudgeToolResult( - tool=request.tool, - status=JudgeToolStatus.SUCCESS, - authority="official_card_data", - provider="local_scryfall_oracle", - purpose=request.purpose, - arguments=request.arguments, - evidence=[ + if request.tool == "oracle_lookup": + evidence = [ { "kind": "card", "identifier": "oracle-young-wolf", @@ -56,7 +49,28 @@ def execute(self, request, *, conversation=None, budget=None): "oracle_text": "Undying (When this creature dies, return it with a +1/+1 counter.)", }, } - ], + ] + authority = "official_card_data" + elif request.tool == "rules_lookup": + evidence = [ + { + "kind": "rule", + "identifier": identifier, + "data": {"number": identifier, "title": f"Rule {identifier}", "rules": []}, + } + for identifier in request.arguments["identifiers"] + ] + authority = "comprehensive_rules" + else: + raise AssertionError(f"unexpected tool: {request.tool}") + return JudgeToolResult( + tool=request.tool, + status=JudgeToolStatus.SUCCESS, + authority=authority, + provider="fake", + purpose=request.purpose, + arguments=request.arguments, + evidence=evidence, ) diff --git a/tests/ui/ui_usability_test.py b/tests/ui/ui_usability_test.py index 25c0c4a..94a400d 100644 --- a/tests/ui/ui_usability_test.py +++ b/tests/ui/ui_usability_test.py @@ -1,172 +1,175 @@ -from html.parser import HTMLParser -from pathlib import Path - -from magicai.ui.routes import INDEX_FILE, UI_ROOT - - -class ControlCollector(HTMLParser): - def __init__(self) -> None: - super().__init__() - self.attributes_by_id: dict[str, dict[str, str | None]] = {} - - def handle_starttag(self, tag: str, attrs) -> None: - attributes = dict(attrs) - element_id = attributes.get("id") - if element_id: - self.attributes_by_id[element_id] = attributes - - -def read_javascript() -> str: - return (UI_ROOT / "app.js").read_text(encoding="utf-8") - - -def read_stylesheet() -> str: - return (UI_ROOT / "app.css").read_text(encoding="utf-8") - - -def test_disambiguation_candidates_are_interactive_and_persisted() -> None: - javascript = read_javascript() - - assert "function getClarificationCandidates(result)" in javascript - assert 'result?.status !== "needs_clarification"' in javascript - assert "function renderDisambiguationActions(candidates, selectedCandidate = null)" in javascript - assert "function submitClarificationCandidate(candidate)" in javascript - assert 'button.className = "clarification-option"' in javascript - assert 'button.addEventListener("click", () => submitClarificationCandidate(candidate))' in javascript - assert "normalizeCandidates(message.candidates)" in javascript - assert "function markClarificationResolved(candidate)" in javascript - assert "message.selectedCandidate = normalized" in javascript - assert "MAX_DISAMBIGUATION_CANDIDATES = 8" in javascript - - -def test_copy_and_export_controls_use_the_last_structured_result() -> None: - parser = ControlCollector() - parser.feed(INDEX_FILE.read_text(encoding="utf-8")) - javascript = read_javascript() - - for control_id in ( - "copy-answer-button", - "copy-evidence-button", - "export-result-button", - "export-feedback-button", - ): - attributes = parser.attributes_by_id[control_id] - assert attributes.get("type") == "button" - assert "disabled" in attributes - - assert "async function copyLastAnswer()" in javascript - assert "async function copyLastEvidence()" in javascript - assert "function buildEvidenceText(result)" in javascript - assert 'appendEvidenceTextSection(lines, "Versiones de fuentes"' in javascript - assert "function exportLastResult()" in javascript - assert 'new Blob([payload], {type: "application/json;charset=utf-8"})' in javascript - assert "magicai-judge-result-${formatExportTimestamp(new Date())}.json" in javascript - assert "function downloadJson(value, filename)" in javascript - assert "function exportFeedbackCase()" in javascript - assert "magicai-community-feedback-${timestamp}.json" in javascript - assert 'artifact_purpose: "evaluation"' in javascript - assert "training_allowed: false" in javascript - assert "automatic_learning: false" in javascript - assert "automatic_promotion: false" in javascript - assert 'mode: "exploratory"' in javascript - assert 'paraphrased: true' in javascript - assert 'contains_verbatim_quote: false' in javascript - assert 'contains_personal_data: false' in javascript - - -def test_evidence_sections_open_from_actual_content() -> None: - javascript = read_javascript() - stylesheet = read_stylesheet() - - assert "function configureEvidenceSections" in javascript - assert '["cards-section", cards.length, cards.length > 0]' in javascript - assert '["warnings-section", warnings.length, warnings.length > 0]' in javascript - assert 'section.classList.toggle("is-empty", count === 0)' in javascript - assert "STATUS_EXPLANATIONS" in javascript - assert 'oracle.className = "oracle-text"' in javascript - assert 'node.className = "evidence-card rule-card"' in javascript - assert ".evidence-card-header" in stylesheet - assert ".oracle-text" in stylesheet - assert ".evidence-note.is-warnings" in stylesheet - - -def test_copy_fallback_and_rendering_remain_local_and_safe() -> None: - javascript = read_javascript() - - assert "navigator.clipboard?.writeText" in javascript - assert 'document.execCommand("copy")' in javascript - assert 'textarea.className = "clipboard-fallback"' in javascript - assert "textContent" in javascript - assert "innerHTML" not in javascript - assert "eval(" not in javascript - - -def test_quickstart_documents_branches_ui_and_ollama_modes() -> None: - quickstart = Path("docs/QUICKSTART.md").read_text(encoding="utf-8") - readme = Path("README.md").read_text(encoding="utf-8") - - assert "git clone https://github.com/Fartis/MagicAI.git" in quickstart - assert "git clone -b develop https://github.com/Fartis/MagicAI.git" in quickstart - assert "http://127.0.0.1:8000/ui" in quickstart - assert "Ollama on the same machine" in quickstart - assert "Ollama in an existing container" in quickstart - assert "Ollama on another LAN machine" in quickstart - assert "231/231" in readme - assert "do **not** mean" in readme - assert "docs/QUICKSTART.md" in readme - assert "# ❤️ A personal letter" in readme - assert "See you in the next game." in readme - assert "v0.1.1-beta" in readme and "Force of Will" in readme - assert "v0.2.0-beta" in readme and "Ponder" in readme - assert "NicolAI Bolas" in readme - - - - - -def test_strategy_handoff_renders_tactician_result_and_combo_trace() -> None: - javascript = read_javascript() - stylesheet = read_stylesheet() - - assert 'profile: result.authority === "tactician" ? "tactician" : state.profile' in javascript - assert "function renderStrategySummary(result)" in javascript - assert "result.combo_steps || []" in javascript - assert "result.outcomes || []" in javascript - assert '["Intent estratégico", result.strategy_intent || "—"]' in javascript - assert '["Clasificación de combo", result.combo_classification || "—"]' in javascript - assert '["Cartas heredadas", (result.inherited_cards || []).join(" · ") || "—"]' in javascript - assert 'container.className = "strategy-summary"' in javascript - assert ".strategy-summary" in stylesheet - -def test_profile_switch_exposes_judge_and_tactician() -> None: - parser = ControlCollector() - parser.feed(INDEX_FILE.read_text(encoding="utf-8")) - javascript = read_javascript() - - assert "judge-profile-button" in parser.attributes_by_id - assert "tactician-profile-button" in parser.attributes_by_id - assert 'function setProfile(profile)' in javascript - assert 'state.profile === "tactician"' in javascript - assert '"/tactician/ask"' in javascript - assert 'Estratega' in javascript - - -def main() -> int: - tests = [ - test_disambiguation_candidates_are_interactive_and_persisted, - test_copy_and_export_controls_use_the_last_structured_result, - test_evidence_sections_open_from_actual_content, - test_copy_fallback_and_rendering_remain_local_and_safe, - test_strategy_handoff_renders_tactician_result_and_combo_trace, - test_profile_switch_exposes_judge_and_tactician, - test_quickstart_documents_branches_ui_and_ollama_modes, - ] - for test in tests: - test() - print(f"OK: {test.__name__}") - print(f"UI usability tests: {len(tests)}/{len(tests)}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) +from html.parser import HTMLParser +from pathlib import Path + +from magicai.ui.routes import INDEX_FILE, UI_ROOT + + +class ControlCollector(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.attributes_by_id: dict[str, dict[str, str | None]] = {} + + def handle_starttag(self, tag: str, attrs) -> None: + attributes = dict(attrs) + element_id = attributes.get("id") + if element_id: + self.attributes_by_id[element_id] = attributes + + +def read_javascript() -> str: + return (UI_ROOT / "app.js").read_text(encoding="utf-8") + + +def read_stylesheet() -> str: + return (UI_ROOT / "app.css").read_text(encoding="utf-8") + + +def test_disambiguation_candidates_are_interactive_and_persisted() -> None: + javascript = read_javascript() + + assert "function getClarificationCandidates(result)" in javascript + assert 'result?.status !== "needs_clarification"' in javascript + assert "function renderDisambiguationActions(candidates, selectedCandidate = null)" in javascript + assert "function submitClarificationCandidate(candidate)" in javascript + assert 'button.className = "clarification-option"' in javascript + assert 'button.addEventListener("click", () => submitClarificationCandidate(candidate))' in javascript + assert "normalizeCandidates(message.candidates)" in javascript + assert "function markClarificationResolved(candidate)" in javascript + assert "message.selectedCandidate = normalized" in javascript + assert "MAX_DISAMBIGUATION_CANDIDATES = 8" in javascript + + +def test_copy_and_export_controls_use_the_last_structured_result() -> None: + parser = ControlCollector() + parser.feed(INDEX_FILE.read_text(encoding="utf-8")) + javascript = read_javascript() + + for control_id in ( + "copy-answer-button", + "copy-evidence-button", + "export-result-button", + "export-feedback-button", + ): + attributes = parser.attributes_by_id[control_id] + assert attributes.get("type") == "button" + assert "disabled" in attributes + + assert "async function copyLastAnswer()" in javascript + assert "async function copyLastEvidence()" in javascript + assert "function buildEvidenceText(result)" in javascript + assert 'appendEvidenceTextSection(lines, "Versiones de fuentes"' in javascript + assert "function exportLastResult()" in javascript + assert 'new Blob([payload], {type: "application/json;charset=utf-8"})' in javascript + assert "magicai-judge-result-${formatExportTimestamp(new Date())}.json" in javascript + assert "function downloadJson(value, filename)" in javascript + assert "function exportFeedbackCase()" in javascript + assert "magicai-community-feedback-${timestamp}.json" in javascript + assert 'artifact_purpose: "evaluation"' in javascript + assert "training_allowed: false" in javascript + assert "automatic_learning: false" in javascript + assert "automatic_promotion: false" in javascript + assert 'mode: "exploratory"' in javascript + assert 'paraphrased: true' in javascript + assert 'contains_verbatim_quote: false' in javascript + assert 'contains_personal_data: false' in javascript + + +def test_evidence_sections_open_from_actual_content() -> None: + javascript = read_javascript() + stylesheet = read_stylesheet() + + assert "function configureEvidenceSections" in javascript + assert '["cards-section", cards.length, cards.length > 0]' in javascript + assert '["warnings-section", warnings.length, warnings.length > 0]' in javascript + assert 'section.classList.toggle("is-empty", count === 0)' in javascript + assert "STATUS_EXPLANATIONS" in javascript + assert 'oracle.className = "oracle-text"' in javascript + assert 'node.className = "evidence-card rule-card"' in javascript + assert ".evidence-card-header" in stylesheet + assert ".oracle-text" in stylesheet + assert ".evidence-note.is-warnings" in stylesheet + + +def test_copy_fallback_and_rendering_remain_local_and_safe() -> None: + javascript = read_javascript() + + assert "navigator.clipboard?.writeText" in javascript + assert 'document.execCommand("copy")' in javascript + assert 'textarea.className = "clipboard-fallback"' in javascript + assert "textContent" in javascript + assert "innerHTML" not in javascript + assert "eval(" not in javascript + + +def test_quickstart_documents_branches_ui_and_ollama_modes() -> None: + quickstart = Path("docs/QUICKSTART.md").read_text(encoding="utf-8") + readme = Path("README.md").read_text(encoding="utf-8") + + assert "git clone https://github.com/Fartis/MagicAI.git" in quickstart + assert "git clone -b develop https://github.com/Fartis/MagicAI.git" in quickstart + assert "http://127.0.0.1:8000/ui" in quickstart + assert "Ollama on the same machine" in quickstart + assert "Ollama in an existing container" in quickstart + assert "Ollama on another LAN machine" in quickstart + assert "231/231" in readme + assert "do **not** mean" in readme + assert "docs/QUICKSTART.md" in readme + assert "# ❤️ A personal letter" in readme + assert "See you in the next game." in readme + assert "v0.1.1-beta" in readme and "Force of Will" in readme + assert "v0.2.0-beta" in readme and "Ponder" in readme + assert "NicolAI Bolas" in readme + + + + + +def test_strategy_handoff_renders_tactician_result_and_combo_trace() -> None: + javascript = read_javascript() + stylesheet = read_stylesheet() + + assert 'profile: result.authority === "tactician" ? "tactician" : state.profile' in javascript + assert "function renderStrategySummary(result)" in javascript + assert "result.combo_steps || []" in javascript + assert "result.outcomes || []" in javascript + assert '["Intent estratégico", result.strategy_intent || "—"]' in javascript + assert '["Clasificación de combo", result.combo_classification || "—"]' in javascript + assert '["Cartas heredadas", (result.inherited_cards || []).join(" · ") || "—"]' in javascript + assert '["Input analizado", result.input_analysis?.speech_act || "—"]' in javascript + assert '["Afirmaciones evaluadas", String((result.claim_verdicts || []).length)]' in javascript + assert '["Verificado por evidencia", result.judge_verified ? "sí" : "no"]' in javascript + assert 'container.className = "strategy-summary"' in javascript + assert ".strategy-summary" in stylesheet + +def test_profile_switch_exposes_judge_and_tactician() -> None: + parser = ControlCollector() + parser.feed(INDEX_FILE.read_text(encoding="utf-8")) + javascript = read_javascript() + + assert "judge-profile-button" in parser.attributes_by_id + assert "tactician-profile-button" in parser.attributes_by_id + assert 'function setProfile(profile)' in javascript + assert 'state.profile === "tactician"' in javascript + assert '"/tactician/ask"' in javascript + assert 'Estratega' in javascript + + +def main() -> int: + tests = [ + test_disambiguation_candidates_are_interactive_and_persisted, + test_copy_and_export_controls_use_the_last_structured_result, + test_evidence_sections_open_from_actual_content, + test_copy_fallback_and_rendering_remain_local_and_safe, + test_strategy_handoff_renders_tactician_result_and_combo_trace, + test_profile_switch_exposes_judge_and_tactician, + test_quickstart_documents_branches_ui_and_ollama_modes, + ] + for test in tests: + test() + print(f"OK: {test.__name__}") + print(f"UI usability tests: {len(tests)}/{len(tests)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From d4508cf8764bbe4be71a693384dec1ea67d2aedd Mon Sep 17 00:00:00 2001 From: Manuel David Villalba Escamilla Date: Thu, 16 Jul 2026 20:44:27 +0200 Subject: [PATCH 5/7] Sprint 12.2c - Harden conversational understanding --- README.md | 4 + docs/API_CONTRACT.md | 13 +- docs/COMMANDS.md | 23 ++ docs/ROADMAP.md | 19 ++ docs/STATUS.md | 46 +-- docs/TACTICIAN.md | 11 +- docs/TACTICIAN_REASONING.md | 94 ++++-- magicai/api/schemas.py | 5 + magicai/assistant/core.py | 1 + magicai/context.py | 6 + magicai/context_builder.py | 65 ++-- magicai/conversation/normalization.py | 128 ++++++++ magicai/knowledge_builder.py | 9 +- magicai/language/__init__.py | 10 + magicai/language/detection.py | 175 ++++++++++ magicai/language/localization.py | 20 ++ magicai/language/policy.py | 36 +++ magicai/prompts/answer.py | 8 + magicai/tactician/answer_contract.py | 175 ++++++++++ magicai/tactician/claims.py | 118 ++++--- magicai/tactician/core.py | 64 +++- magicai/tactician/input_analysis.py | 145 +++++---- magicai/tactician/intents.py | 162 ++++------ magicai/tactician/models.py | 10 + magicai/tactician/planner.py | 126 ++++++-- magicai/tactician/synthesis.py | 300 +++++++++++------- magicai/ui/static/app.js | 4 + magicai/validation/rule_renderer.py | 34 ++ magicai/versioning.py | 4 +- scripts/ci_check.py | 5 + tests/api/tactician_api_contract_test.py | 8 + .../conversation/casual_normalization_test.py | 39 +++ tests/language/__init__.py | 0 tests/language/language_policy_test.py | 43 +++ .../tactician_conversations/sprint12_2c.json | 35 ++ .../tactician_conversation_contract_test.py | 26 ++ .../tactician_conversation_regression_test.py | 134 ++++++++ .../tactician_conversations/__init__.py | 4 + .../tactician_conversations/evaluator.py | 28 ++ .../quality/tactician_conversations/loader.py | 12 + tests/tactician/tactician_core_test.py | 4 + tests/ui/ui_usability_test.py | 3 + .../casual_judge_understanding_test.py | 35 ++ 43 files changed, 1768 insertions(+), 423 deletions(-) create mode 100644 magicai/conversation/normalization.py create mode 100644 magicai/language/__init__.py create mode 100644 magicai/language/detection.py create mode 100644 magicai/language/localization.py create mode 100644 magicai/language/policy.py create mode 100644 magicai/tactician/answer_contract.py create mode 100644 tests/conversation/casual_normalization_test.py create mode 100644 tests/language/__init__.py create mode 100644 tests/language/language_policy_test.py create mode 100644 tests/quality/cases/tactician_conversations/sprint12_2c.json create mode 100644 tests/quality/tactician_conversation_contract_test.py create mode 100644 tests/quality/tactician_conversation_regression_test.py create mode 100644 tests/quality/tactician_conversations/__init__.py create mode 100644 tests/quality/tactician_conversations/evaluator.py create mode 100644 tests/quality/tactician_conversations/loader.py create mode 100644 tests/validation/casual_judge_understanding_test.py diff --git a/README.md b/README.md index 60289a1..b466de2 100644 --- a/README.md +++ b/README.md @@ -73,10 +73,14 @@ The current Tactician milestone adds: Automatic strategic handoff Conversation-card inheritance Structured input and claim analysis +Session-aware language policy +Shared casual-language normalization Bounded multi-tool investigation plans Claim verdicts with source identifiers +Semantic answer obligations and completeness checks Independent conversational synthesis Strategic follow-up intents and persisted context +Data-driven multi-turn conversation regression seed Generic three-piece Undying loop detection Structured combo steps and outcomes ``` diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 9a09e8a..35ac3b5 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -1,11 +1,11 @@ # API contract -Current API contract version: `1.5`. +Current API contract version: `1.6`. ## Stable result families - Judge results use JudgeResult schema `1.0`. -- Tactician results use TacticianResult schema `0.4`. +- Tactician results use TacticianResult schema `0.5`. - Judge tool results use JudgeToolResult schema `1.0`. ## Main endpoints @@ -55,7 +55,7 @@ Unavailable capabilities return a structured result instead of being guessed. ## Tactician reasoning fields -TacticianResult `0.4` adds: +TacticianResult `0.5` includes: - `input_analysis` - `claim_verdicts` @@ -64,5 +64,10 @@ TacticianResult `0.4` adds: - `queries_completed` - `judge_verified` - `investigation_plan` +- `response_language` +- `language_policy` +- `answer_obligations` +- `answer_contract` +- `answer_complete` -These fields expose a concise, structured audit trail. They are not a hidden chain-of-thought transcript. +These fields expose a concise, structured audit trail. They are not a hidden chain-of-thought transcript. `answer_complete` means the generated answer satisfied its deterministic semantic obligations; `judge_verified` additionally requires supporting Judge-owned evidence. diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 84cba1f..13c9f19 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -137,3 +137,26 @@ curl -s -X POST http://127.0.0.1:8000/judge/tools/execute \ "purpose": "verify_undying_sacrifice" }' | python -m json.tool ``` + +## Tactician conversational understanding + +Run the language and casual-input checks: + +```bash +python -m tests.language.language_policy_test +python -m tests.conversation.casual_normalization_test +python -m tests.validation.casual_judge_understanding_test +``` + +Run the seed multi-turn conversation regression: + +```bash +python -m tests.quality.tactician_conversation_contract_test +python -m tests.quality.tactician_conversation_regression_test +``` + +The seed scenario is stored in: + +```text +tests/quality/cases/tactician_conversations/sprint12_2c.json +``` diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 000ec7c..b1fa518 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -72,6 +72,25 @@ The first major release should add mature deck analysis, authorized strategic so - Warm, interactive response style for supported families. - Claim verdicts and evidence identifiers. - Evidence-verification metadata. + +### 12.2c — Conversational understanding and answer validation — complete + +- Session-aware language policy. +- Shared casual-language normalization for Judge and Tactician. +- Professional Judge output regardless of user register. +- Rules-oriented Tactician intents. +- Question targets separated from factual claims. +- Semantic answer obligations and forbidden-drift checks. +- Evidence-aware `judge_verified` and confidence derivation. +- Seed data-driven multi-turn conversation regression. + +### 12.2d — Tactician Conversation Gauntlet — next + +- Reusable multi-turn scenario execution. +- Structured semantic and negative assertions. +- Controlled evidence fixtures and optional Ollama mode. +- Failure classification and JSON/HTML reports. +- Manual promotion of reviewed UI feedback exports. - Young Wolf, Carrion Feeder, and The Ozolith reasoning regression. - Ghave and Ashnod's Altar follow-up sequencing and disruption regression. diff --git a/docs/STATUS.md b/docs/STATUS.md index 9bfafd4..f10ac8a 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -9,37 +9,41 @@ ## Current development line -Sprint `12.2b` introduces structured input reasoning and conversational strategic orchestration. +Sprint `12.2c` adds conversational understanding, language consistency, and semantic answer validation. Completed in this milestone: -- speech-act and strategic-intent analysis; -- deterministic claim extraction; -- bounded multi-tool investigation plans; -- claim verdicts with source identifiers; -- independent Tactician synthesis instead of Judge-answer relay; -- conversational correction of user hypotheses; -- persisted strategic conversation context; -- play-sequence, disruption, and requirements follow-ups; -- TacticianResult schema `0.4` and API contract `1.5`; -- focused input-reasoning and conversation-state regressions. +- session-aware Spanish and English language policy; +- English MTG names and keywords excluded from strong language evidence; +- shared casual-language normalization for the Judge and Tactician; +- professional Judge responses after colloquial user input; +- rules-oriented Tactician intents; +- open questions separated from factual claims; +- evidence-aware mechanic-equivalence verdicts; +- deterministic answer obligations and forbidden-drift checks; +- corrected `judge_verified` and confidence derivation when evidence is complete; +- TacticianResult schema `0.5` and API contract `1.6`; +- first data-driven multi-turn Tactician conversation regression. ## Next development line -Sprint `12.3` will focus on general autonomous investigation planning: +Sprint `12.2d` will build the wider Tactician Conversation Gauntlet: -- broader claim decomposition; -- iterative evidence-gap detection; -- evidence sufficiency scoring; -- alternative and counterexample search; -- configurable investigation depth; -- wider strategic families beyond the current deterministic vertical slices. +- reusable multi-turn scenario runner; +- controlled Judge Tool Gateway fixtures; +- semantic and negative assertions; +- optional Ollama execution mode; +- JSON and HTML failure reports; +- manual promotion of exported feedback into reviewed regression candidates. + +Sprint `12.3` will then focus on general autonomous investigation planning. ## Known limitations -- Multi-query planning is implemented for supported concepts, but is not yet general across arbitrary interactions. -- Combo reconstruction covers only a narrow generic family. +- Casual-language normalization is conservative and currently covers a bounded vocabulary. +- Semantic answer contracts are implemented for the first rules-oriented and combo-requirement families. +- Multi-query planning is not yet general across arbitrary interactions. +- Combo reconstruction covers only narrow generic families. - Commander Spellbook is not connected. - EDHREC-style statistics remain permission-gated. - A full deck analyzer and collection provider are not yet implemented. -- Some Judge fallbacks may still require stronger second-pass validation. diff --git a/docs/TACTICIAN.md b/docs/TACTICIAN.md index 05c020e..f4ae1ce 100644 --- a/docs/TACTICIAN.md +++ b/docs/TACTICIAN.md @@ -15,7 +15,7 @@ The Tactician analyzes: It does not open Oracle, rules, rulings, Commander Spellbook, EDHREC, or user collection files directly. It requests structured evidence through the Judge Tool Gateway. -## Current milestone: 0.4 +## Current milestone: 0.5 Implemented: @@ -36,7 +36,12 @@ Implemented: - independent conversational synthesis even when the Judge already returned a factual answer; - persisted strategic conversation context; - `play_sequence`, `combo_disruption`, `combo_requirements`, and `interaction_hypothesis` intents; -- evidence-verification metadata and concise reasoning summaries. +- evidence-verification metadata and concise reasoning summaries; +- session-aware language policy that ignores English card names as language evidence; +- shared casual-language normalization for the Judge and Tactician; +- rules-oriented intents such as `mechanic_equivalence` and `combo_failure_explanation`; +- semantic answer obligations and `answer_complete` validation; +- the first data-driven multi-turn Tactician conversation regression. ## Evidence loop @@ -53,7 +58,7 @@ Tactician forms a hypothesis → publish or declare uncertainty ``` -Milestone 0.4 provides deterministic claim extraction, bounded multi-tool planning, evidence verdicts, and conversational synthesis. General autonomous planning remains the next milestone. +Milestone 0.5 adds language consistency, casual-input normalization, rules-oriented follow-ups, and answer-contract validation. The wider conversation gauntlet and general autonomous planner remain later milestones. ## Personality diff --git a/docs/TACTICIAN_REASONING.md b/docs/TACTICIAN_REASONING.md index d192aef..aec7bc7 100644 --- a/docs/TACTICIAN_REASONING.md +++ b/docs/TACTICIAN_REASONING.md @@ -1,37 +1,83 @@ # Tactician input reasoning -Sprint 12.2b adds a structured reasoning layer between the user's message and strategic synthesis. +Sprint 12.2c extends the structured reasoning layer introduced in Sprint 12.2b. The Tactician must understand the user's current question, preserve the conversation language, request the relevant rules evidence, and prove that its final answer satisfies the question it was asked. ## Goal -The Tactician must not treat the Judge's prose as the answer. It first analyzes what the player is asserting, asking, correcting, or challenging, then requests the evidence needed to evaluate those claims. +The Tactician must not treat the Judge's prose as the answer. It analyzes what the player is asserting, asking, correcting, or challenging, then requests the evidence needed to evaluate those claims. A completed response must also satisfy an explicit answer contract. ## Pipeline ```text -user input +user wording + → shared casual-language normalization + → session language policy → speech-act and intent analysis - → claim extraction + → claim and question-target extraction → bounded investigation plan → Judge Tool Gateway calls → claim verdicts → conversational strategic synthesis + → answer-obligation validation → evidence verification metadata ``` +## Language policy + +Card names and rules keywords are often English even inside a Spanish sentence. They are treated as neutral MTG terms instead of strong language evidence. + +Resolution order: + +```text +clear current-turn language + → established session language + → configured default language +``` + +The selected language applies to the answer and to user-visible strategic metadata such as risks, outcomes, claim explanations, and reasoning summaries. + +## Casual-language normalization + +The Judge and Tactician share a conservative normalizer. It converts colloquial wording into retrieval-friendly wording without answering the question or silently correcting its premise. + +Examples: + +```text +"pisa cementerio" → "is put into a graveyard" +"pisa mesa" → "enters the battlefield" +"se puede cortar" → "can be responded to or interrupted" +``` + +The Judge still answers in a stable, professional register. The Tactician may remain warmer and more conversational. + ## Input analysis The analyzer records: -- language; +- response language and language-policy decision; +- user register; +- canonical retrieval question; - speech act such as question, hypothesis, challenge, or follow-up; -- strategic intent; +- strategic or rules-oriented intent; +- question target and answer focus; - causal markers; - detected concepts; - structured claims. The result is auditable metadata, not a hidden chain-of-thought transcript. +## Rules-oriented intents + +Sprint 12.2c adds: + +- `rules_clarification` +- `mechanic_definition` +- `mechanic_equivalence` +- `combo_failure_explanation` +- `interaction_timing` + +The Tactician owns the conversation, but factual rules claims remain Judge-owned and must be recovered through the Judge Tool Gateway. + ## Claim verdicts Each extracted claim can be classified as: @@ -42,11 +88,23 @@ Each extracted claim can be classified as: - `insufficient_evidence` - `strategic_opinion` -A verdict records a concise explanation and the source identifiers that support it. +A verdict records a concise explanation and the source identifiers that support it. Open questions are not automatically converted into factual claims. + +## Answer obligations + +Each response receives a small semantic contract. For an Undying terminology question, the required obligations include: + +- define `dies`; +- explain that dying and moving from the battlefield to a graveyard are the same event; +- exclude graveyard entry from other zones; +- apply the definition to Undying; +- apply it to the active interaction when relevant. + +The response is marked `answer_complete` only when all required checks pass and forbidden drift is absent. ## Current deterministic interaction family -The first full reasoning family covers: +The primary regression covers: - Young Wolf; - Carrion Feeder; @@ -54,21 +112,21 @@ The first full reasoning family covers: - Undying; - counters across zone changes; - leaves-the-battlefield timing; -- last known information. +- last known information; +- casual follow-up wording about dying and entering a graveyard. -The Tactician correctly explains that The Ozolith does not remove Young Wolf's +1/+1 counter before Undying checks the creature's last battlefield state. +The Tactician explains that The Ozolith does not remove Young Wolf's +1/+1 counter before Undying checks the creature's last battlefield state. -## Strategic follow-ups +## Conversation regression seed -The active strategic context now supports follow-up intents such as: +The first multi-turn scenario is stored under: -- `play_sequence` -- `combo_disruption` -- `combo_requirements` -- `interaction_hypothesis` +```text +tests/quality/cases/tactician_conversations/sprint12_2c.json +``` -The first supported sequence family covers Young Wolf, Ashnod's Altar, and Ghave, Guru of Spores. +It verifies language, intent, required rules, semantic requirements, forbidden drift, answer completeness, and evidence verification. This is the seed for the wider Tactician Conversation Gauntlet planned for Sprint 12.2d. ## Limits -This milestone is not a universal natural-language theorem prover. Claim extraction and verdict generation are deterministic and intentionally narrow. Sprint 12.3 will generalize investigation planning, evidence sufficiency, alternatives, and counterexample search. +This milestone is not a universal natural-language theorem prover. Normalization, claim extraction, semantic checks, and verdict generation remain deterministic and intentionally bounded. Sprint 12.2d will broaden multi-turn scenario execution and reporting; Sprint 12.3 will generalize autonomous investigation planning. diff --git a/magicai/api/schemas.py b/magicai/api/schemas.py index 026d006..0693e0a 100644 --- a/magicai/api/schemas.py +++ b/magicai/api/schemas.py @@ -77,6 +77,11 @@ class AskResponse(BaseModel): queries_completed: int = 0 judge_verified: bool = False investigation_plan: dict[str, Any] = Field(default_factory=dict) + response_language: str = "" + language_policy: dict[str, Any] = Field(default_factory=dict) + answer_obligations: list[dict[str, Any]] = Field(default_factory=list) + answer_contract: dict[str, Any] = Field(default_factory=dict) + answer_complete: bool = False judge_result: dict[str, Any] = Field(default_factory=dict) diff --git a/magicai/assistant/core.py b/magicai/assistant/core.py index a47f79f..391ccb7 100644 --- a/magicai/assistant/core.py +++ b/magicai/assistant/core.py @@ -136,6 +136,7 @@ def _update_conversation_state(conversation, context) -> None: conversation.active_rules = _rule_identifiers(context.rules) conversation.active_rule_queries = list(context.rule_queries) conversation.last_intent = context.intent + conversation.language = context.language def _rule_identifiers(rules: list) -> list[str]: diff --git a/magicai/context.py b/magicai/context.py index 008d495..939c6d0 100644 --- a/magicai/context.py +++ b/magicai/context.py @@ -10,6 +10,12 @@ class AssistantContext: language: str = "es" + canonical_question: str = "" + + input_register: str = "standard" + + normalization: dict = field(default_factory=dict) + cards: list = field(default_factory=list) keywords: list[str] = field(default_factory=list) diff --git a/magicai/context_builder.py b/magicai/context_builder.py index e77a22b..6527adc 100644 --- a/magicai/context_builder.py +++ b/magicai/context_builder.py @@ -2,6 +2,8 @@ import unicodedata from magicai.context import AssistantContext +from magicai.conversation.normalization import normalize_user_question +from magicai.language.policy import resolve_language_policy from magicai.extractors.cards import extract_cards from magicai.extractors.keywords import extract_keywords @@ -83,25 +85,42 @@ def build_context(conversation, question: str): - intent = parse_intent(question) - - language = "es" + language_policy = resolve_language_policy( + question, + session_language=getattr(conversation, "language", "es"), + ) + normalized_question = normalize_user_question( + question, + session_language=language_policy.response_language, + ) + canonical_question = normalized_question.canonical or question + intent = parse_intent(canonical_question) + language = language_policy.response_language - explicit_cards = extract_cards(question) - explicit_keywords = extract_keywords(question) - explicit_rules = extract_rules(question) + explicit_cards = _merge_unique( + extract_cards(question), + extract_cards(canonical_question), + ) + explicit_keywords = _merge_unique( + extract_keywords(question), + extract_keywords(canonical_question), + ) + explicit_rules = _merge_unique( + extract_rules(question), + extract_rules(canonical_question), + ) follow_up = _looks_like_follow_up( - question, + canonical_question, has_history=len(conversation.history) > 1, ) - comparison = _looks_like_comparison(question) + comparison = _looks_like_comparison(canonical_question) # A word can be both a card name and a keyword. In an established rules # conversation, the keyword interpretation wins unless the user explicitly # asks for the card or its Oracle text. explicit_cards = _prefer_mechanics_in_rule_context( - question=question, + question=canonical_question, cards=explicit_cards, keywords=explicit_keywords, active_keywords=conversation.active_keywords, @@ -110,7 +129,7 @@ def build_context(conversation, question: str): ) cards = _resolve_cards( - question=question, + question=canonical_question, explicit_cards=explicit_cards, active_cards=conversation.active_cards, follow_up=follow_up, @@ -118,8 +137,8 @@ def build_context(conversation, question: str): rule_topic=bool( explicit_keywords or explicit_rules - or looks_like_general_rule_question(question) - or _looks_like_procedural_rule_topic(question) + or looks_like_general_rule_question(canonical_question) + or _looks_like_procedural_rule_topic(canonical_question) ), ) @@ -128,20 +147,20 @@ def build_context(conversation, question: str): active_keywords=conversation.active_keywords, follow_up=follow_up, comparison=comparison, - question=question, + question=canonical_question, ) rules = _resolve_rules( explicit_rules=explicit_rules, active_rules=conversation.active_rules, follow_up=follow_up, - question=question, + question=canonical_question, ) - action_terms = extract_action_search_terms(question) + action_terms = extract_action_search_terms(canonical_question) direct_rule_queries = build_rule_queries( - question=question, + question=canonical_question, keywords=keywords, action_terms=action_terms, ) @@ -149,11 +168,11 @@ def build_context(conversation, question: str): # A referential request to explain a numbered rule should keep that exact # rule as the sole retrieval anchor. Generic words such as "ejemplo" or # "explicarla" otherwise retrieve unrelated rules and dilute the source. - if rules and not explicit_rules and _looks_like_rule_follow_up(question): + if rules and not explicit_rules and _looks_like_rule_follow_up(canonical_question): direct_rule_queries = [] rule_queries, inherited_rule_queries = _resolve_rule_queries( - question=question, + question=canonical_question, direct_queries=direct_rule_queries, active_queries=conversation.active_rule_queries, explicit_cards=explicit_cards, @@ -170,6 +189,12 @@ def build_context(conversation, question: str): language=language, + canonical_question=canonical_question, + + input_register=normalized_question.register, + + normalization=normalized_question.to_dict(), + cards=cards, keywords=keywords, @@ -179,7 +204,7 @@ def build_context(conversation, question: str): rule_queries=rule_queries, facts=build_reasoning( - question, + canonical_question, language=language, ), @@ -328,7 +353,7 @@ def _prefer_mechanics_in_rule_context( or follow_up or comparison or direct_mechanic_question - or looks_like_general_rule_question(question) + or looks_like_general_rule_question(canonical_question) ): return cards diff --git a/magicai/conversation/normalization.py b/magicai/conversation/normalization.py new file mode 100644 index 0000000..7278011 --- /dev/null +++ b/magicai/conversation/normalization.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +import re +import unicodedata + +from magicai.language.policy import resolve_language_policy + + +@dataclass(frozen=True, slots=True) +class NormalizedUserQuestion: + original: str + canonical: str + language: str + register: str + concepts: tuple[str, ...] = () + transformations: tuple[str, ...] = () + ambiguities: tuple[str, ...] = () + + def to_dict(self) -> dict[str, object]: + return { + "original": self.original, + "canonical_question": self.canonical, + "language": self.language, + "register": self.register, + "concepts": list(self.concepts), + "transformations": list(self.transformations), + "ambiguities": list(self.ambiguities), + } + + +# The normalizer maps colloquial wording to retrieval-friendly wording. It does +# not answer the question and it does not silently correct a factual premise. +_SPANISH_REWRITES = ( + (r"\bpisa(?:r)?\s+(?:el\s+)?cementerio\b", "es puesta en un cementerio" , "graveyard_colloquial"), + (r"\bva\s+al\s+hoyo\b", "va al cementerio", "graveyard_colloquial"), + (r"\bmanda(?:r)?\s+al\s+hoyo\b", "pone en el cementerio", "graveyard_colloquial"), + (r"\bse\s+va\s+al\s+hoyo\b", "es puesta en el cementerio", "graveyard_colloquial"), + (r"\bpisa(?:r)?\s+mesa\b", "entra al campo de batalla", "battlefield_colloquial"), + (r"\bbaja(?:r)?\s+a\s+mesa\b", "entra al campo de batalla", "battlefield_colloquial"), + (r"\bse\s+lleva\s+puesto\s+el\s+contador\b", "conserva el contador al cambiar de zona", "counter_colloquial"), + (r"\bse\s+puede\s+cortar\b", "se puede responder o interrumpir", "interaction_colloquial"), + (r"\bme\s+pueden\s+reventar\s+el\s+combo\b", "pueden interrumpir el combo", "interaction_colloquial"), + (r"\bhacer\s+la\s+pirula\b", "repetir la interacción", "strategy_colloquial"), +) + +_ENGLISH_REWRITES = ( + (r"\bhits?\s+the\s+graveyard\b", "is put into a graveyard", "graveyard_colloquial"), + (r"\bhits?\s+the\s+board\b", "enters the battlefield", "battlefield_colloquial"), +) + + +def normalize_user_question( + text: str, + *, + session_language: str | None = None, +) -> NormalizedUserQuestion: + original = " ".join((text or "").strip().split()) + policy = resolve_language_policy(original, session_language=session_language) + canonical = original + transformations: list[str] = [] + + rewrites = _SPANISH_REWRITES if policy.response_language == "es" else _ENGLISH_REWRITES + for pattern, replacement, code in rewrites: + updated, count = re.subn(pattern, replacement, canonical, flags=re.IGNORECASE) + if count: + canonical = updated + transformations.append(code) + + canonical = " ".join(canonical.split()) + concepts = _detect_concepts(canonical) + register = "casual" if transformations or _looks_casual(original) else "standard" + + return NormalizedUserQuestion( + original=original, + canonical=canonical, + language=policy.response_language, + register=register, + concepts=tuple(concepts), + transformations=tuple(_deduplicate(transformations)), + ambiguities=(), + ) + + +def _detect_concepts(text: str) -> list[str]: + normalized = _normalize(text) + mappings = ( + ("graveyard", ("cementerio", "graveyard")), + ("battlefield", ("campo de batalla", "battlefield")), + ("dies", ("muere", "morir", "dies", "put into a graveyard from the battlefield")), + ("undying", ("undying",)), + ("counter", ("contador", "counter")), + ("trigger", ("dispara", "activa", "trigger")), + ("combo", ("combo", "bucle", "loop", "infinito", "infinite")), + ("interaction", ("interrump", "responder", "interaction", "cortar")), + ) + result: list[str] = [] + for concept, markers in mappings: + if any(marker in normalized for marker in markers): + result.append(concept) + return result + + +def _looks_casual(text: str) -> bool: + normalized = _normalize(text) + markers = ( + "hoyo", + "pisa mesa", + "pisa cementerio", + "pirula", + "reventar el combo", + "se lleva puesto", + ) + return any(marker in normalized for marker in markers) + + +def _normalize(text: str) -> str: + value = unicodedata.normalize("NFKD", text or "") + value = "".join(char for char in value if not unicodedata.combining(char)) + return re.sub(r"\s+", " ", value.casefold()).strip() + + +def _deduplicate(items: list[str]) -> list[str]: + result: list[str] = [] + for item in items: + if item and item not in result: + result.append(item) + return result diff --git a/magicai/knowledge_builder.py b/magicai/knowledge_builder.py index 1e830f0..174689b 100644 --- a/magicai/knowledge_builder.py +++ b/magicai/knowledge_builder.py @@ -4,9 +4,16 @@ def build_knowledge(context): parts.append("QUESTION") parts.append("") - parts.append(context.question) + parts.append(context.canonical_question or context.question) parts.append("") + if getattr(context, "canonical_question", "") and context.canonical_question != context.question: + parts.append("=" * 60) + parts.append("ORIGINAL USER WORDING") + parts.append("") + parts.append(context.question) + parts.append("") + if context.cards: parts.append("=" * 60) diff --git a/magicai/language/__init__.py b/magicai/language/__init__.py new file mode 100644 index 0000000..7f8946a --- /dev/null +++ b/magicai/language/__init__.py @@ -0,0 +1,10 @@ +from magicai.language.detection import LanguageDecision, detect_language +from magicai.language.policy import resolve_language_policy +from magicai.language.localization import localize_messages + +__all__ = [ + "LanguageDecision", + "detect_language", + "resolve_language_policy", + "localize_messages", +] diff --git a/magicai/language/detection.py b/magicai/language/detection.py new file mode 100644 index 0000000..a798a31 --- /dev/null +++ b/magicai/language/detection.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from dataclasses import dataclass +import re +import unicodedata + + +@dataclass(frozen=True, slots=True) +class LanguageDecision: + input_language: str + session_language: str + response_language: str + confidence: str + locked: bool + spanish_score: int + english_score: int + + def to_dict(self) -> dict[str, object]: + return { + "input_language": self.input_language, + "session_language": self.session_language, + "response_language": self.response_language, + "confidence": self.confidence, + "language_locked": self.locked, + "spanish_score": self.spanish_score, + "english_score": self.english_score, + } + + +# Card names, rules keywords, mana symbols, and format names are deliberately +# ignored. They are normally English even inside an otherwise Spanish turn. +_NEUTRAL_MTG_TERMS = { + "undying", + "persist", + "ward", + "commander", + "modern", + "pioneer", + "legacy", + "standard", + "oracle", + "trigger", + "stack", + "the", + "ozolith", + "young", + "wolf", + "carrion", + "feeder", + "ashnod", + "altar", + "ghave", + "guru", + "of", + "spores", +} + +_SPANISH_STRONG = { + "entonces", + "cuando", + "muere", + "morir", + "criatura", + "cementerio", + "campo", + "batalla", + "contador", + "contadores", + "porque", + "porqué", + "aunque", + "pero", + "tiene", + "hace", + "puedo", + "puede", + "vuelve", + "entra", + "sale", + "sacrifica", + "sacrifico", + "necesito", + "antes", + "después", + "despues", + "mismo", + "misma", + "no", + "sí", + "si", + "qué", + "que", + "cómo", + "como", + "cuándo", + "dónde", + "donde", +} + +_ENGLISH_STRONG = { + "then", + "when", + "dies", + "creature", + "graveyard", + "battlefield", + "counter", + "counters", + "because", + "although", + "but", + "does", + "returns", + "enters", + "leaves", + "sacrifice", + "need", + "before", + "after", + "same", + "what", + "how", + "why", + "where", +} + +_SPANISH_FUNCTION = { + "el", "la", "los", "las", "un", "una", "de", "del", "al", "en", + "por", "para", "con", "sin", "y", "o", "es", "son", "se", "lo", + "le", "su", "sus", "mi", "tu", "ya", "más", "mas", "eso", "esto", +} + +_ENGLISH_FUNCTION = { + "a", "an", "and", "or", "is", "are", "to", "from", "in", "on", + "with", "without", "it", "its", "this", "that", "the", "my", "your", +} + + +def detect_language(text: str, *, default: str = "es") -> tuple[str, str, int, int]: + raw = text or "" + normalized = _normalize(raw) + tokens = [token for token in re.findall(r"[a-z0-9+/'-]+", normalized) if token] + semantic_tokens = [token for token in tokens if token not in _NEUTRAL_MTG_TERMS] + + spanish_score = 0 + english_score = 0 + + if "¿" in raw or "¡" in raw: + spanish_score += 4 + if re.search(r"[áéíóúüñ]", raw.casefold()): + spanish_score += 3 + + for token in semantic_tokens: + if token in _SPANISH_STRONG: + spanish_score += 2 + if token in _ENGLISH_STRONG: + english_score += 2 + if token in _SPANISH_FUNCTION: + spanish_score += 1 + if token in _ENGLISH_FUNCTION: + english_score += 1 + + if spanish_score >= english_score + 2: + return "es", "high" if spanish_score >= 5 else "medium", spanish_score, english_score + if english_score >= spanish_score + 2: + return "en", "high" if english_score >= 5 else "medium", spanish_score, english_score + + normalized_default = default if default in {"es", "en"} else "es" + return normalized_default, "low", spanish_score, english_score + + +def _normalize(text: str) -> str: + value = unicodedata.normalize("NFKD", text or "") + value = "".join(char for char in value if not unicodedata.combining(char)) + return re.sub(r"\s+", " ", value.casefold()).strip() diff --git a/magicai/language/localization.py b/magicai/language/localization.py new file mode 100644 index 0000000..2ece514 --- /dev/null +++ b/magicai/language/localization.py @@ -0,0 +1,20 @@ +from __future__ import annotations + + +_KNOWN_MESSAGES_ES = { + "A strategic recommendation requires Estratega; the Judge only validates recovered facts.": ( + "Una recomendación estratégica requiere al Estratega; el Juez solo valida los hechos recuperados." + ), + "The Judge could not close the factual package; the Strategist's conclusion is limited to the evidence recovered so far.": ( + "El Juez no pudo cerrar el paquete factual; la conclusión del Estratega queda limitada a la evidencia recuperada." + ), + "The current evidence package does not prove or disprove this claim yet.": ( + "La evidencia disponible todavía no permite confirmar ni refutar esta afirmación." + ), +} + + +def localize_messages(messages: list[str], language: str) -> list[str]: + if language != "es": + return list(messages) + return [_KNOWN_MESSAGES_ES.get(message, message) for message in messages] diff --git a/magicai/language/policy.py b/magicai/language/policy.py new file mode 100644 index 0000000..8d0057a --- /dev/null +++ b/magicai/language/policy.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from magicai.language.detection import LanguageDecision, detect_language + + +def resolve_language_policy( + text: str, + *, + session_language: str | None = None, + default_language: str = "es", +) -> LanguageDecision: + session = session_language if session_language in {"es", "en"} else default_language + input_language, confidence, spanish_score, english_score = detect_language( + text, + default=session, + ) + + # A conversation only changes language when the current turn gives a clear + # signal. Ambiguous turns containing English card names or rules keywords + # inherit the established session language. + if confidence == "high": + response_language = input_language + locked = True + else: + response_language = session + locked = bool(session_language in {"es", "en"}) + + return LanguageDecision( + input_language=input_language, + session_language=session, + response_language=response_language, + confidence=confidence, + locked=locked, + spanish_score=spanish_score, + english_score=english_score, + ) diff --git a/magicai/prompts/answer.py b/magicai/prompts/answer.py index dc705b8..d36a726 100644 --- a/magicai/prompts/answer.py +++ b/magicai/prompts/answer.py @@ -36,6 +36,14 @@ - Oracle text and rules may be written in English. - Do not switch to English when QUESTION is in Spanish. +Register rule: + +- The user's original wording may be casual, abbreviated, or use imprecise game terminology. +- Interpret it through QUESTION and the recovered evidence. +- Keep the final Judge answer professional, precise, and stable. +- Do not imitate slang from ORIGINAL USER WORDING. +- Correct terminology politely when that correction is material to the answer. + Spanish wording rules: - Use "cementerio" for graveyard. diff --git a/magicai/tactician/answer_contract.py b/magicai/tactician/answer_contract.py new file mode 100644 index 0000000..0798f0a --- /dev/null +++ b/magicai/tactician/answer_contract.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +import re +import unicodedata + +from magicai.tactician.input_analysis import InputAnalysis +from magicai.tactician.intents import StrategyIntent + + +@dataclass(frozen=True, slots=True) +class AnswerObligation: + code: str + description: str + required: bool = True + + def to_dict(self) -> dict[str, object]: + return { + "code": self.code, + "description": self.description, + "required": self.required, + } + + +@dataclass(slots=True) +class AnswerContractResult: + obligations: list[AnswerObligation] = field(default_factory=list) + checks: dict[str, bool] = field(default_factory=dict) + forbidden_checks: dict[str, bool] = field(default_factory=dict) + + @property + def complete(self) -> bool: + required_codes = {item.code for item in self.obligations if item.required} + return all(self.checks.get(code, False) for code in required_codes) and not any(self.forbidden_checks.values()) + + def to_dict(self) -> dict[str, object]: + return { + "obligations": [item.to_dict() for item in self.obligations], + "checks": dict(self.checks), + "forbidden_checks": dict(self.forbidden_checks), + "answer_complete": self.complete, + } + + +def build_answer_obligations(analysis: InputAnalysis, *, has_current_interaction: bool) -> list[AnswerObligation]: + spanish = analysis.language == "es" + intent = analysis.strategy_intent + obligations: list[AnswerObligation] = [] + + if intent is StrategyIntent.MECHANIC_EQUIVALENCE: + obligations.extend([ + AnswerObligation("define_dies", "Definir qué significa morir." if spanish else "Define what dying means."), + AnswerObligation("compare_events", "Aclarar si morir y pasar del campo al cementerio son el mismo evento." if spanish else "Clarify whether dying and moving from battlefield to graveyard are the same event."), + AnswerObligation("exclude_other_zone_changes", "Aclarar que entrar al cementerio desde otra zona no es morir." if spanish else "Clarify that entering a graveyard from another zone is not dying."), + AnswerObligation("apply_undying", "Aplicar la definición a Undying." if spanish else "Apply the definition to Undying."), + ]) + if has_current_interaction: + obligations.append(AnswerObligation( + "apply_current_interaction", + "Aplicar la regla a la interacción activa." if spanish else "Apply the rule to the active interaction.", + )) + elif intent is StrategyIntent.COMBO_FAILURE_EXPLANATION: + obligations.extend([ + AnswerObligation("identify_failed_transition", "Identificar el paso exacto que impide el bucle." if spanish else "Identify the exact transition that stops the loop."), + AnswerObligation("explain_rule", "Explicar la regla que produce ese resultado." if spanish else "Explain the rule producing that result."), + AnswerObligation("apply_current_interaction", "Aplicar la regla a las piezas activas." if spanish else "Apply the rule to the active pieces."), + ]) + elif intent is StrategyIntent.COMBO_REQUIREMENTS: + obligations.extend([ + AnswerObligation("list_required_permanents", "Enumerar las piezas necesarias." if spanish else "List the required pieces."), + AnswerObligation("list_initial_state", "Indicar el estado inicial requerido." if spanish else "State the required initial state."), + ]) + else: + obligations.append(AnswerObligation( + "direct_user_question", + "Responder directamente a la pregunta actual." if spanish else "Answer the current question directly.", + )) + return obligations + + +def validate_answer_contract( + answer: str, + *, + analysis: InputAnalysis, + obligations: list[AnswerObligation], + has_current_interaction: bool, +) -> AnswerContractResult: + normalized = _normalize(answer) + checks: dict[str, bool] = {} + for obligation in obligations: + checks[obligation.code] = _check_obligation( + obligation.code, + normalized, + analysis=analysis, + has_current_interaction=has_current_interaction, + ) + + forbidden_checks = { + "language_switch": _wrong_language(answer, expected=analysis.language), + "player_loses_game_drift": any( + marker in normalized + for marker in ( + "jugador pierde el juego", + "abdica por empate", + "player loses the game", + "concedes the game", + ) + ) and analysis.strategy_intent is StrategyIntent.MECHANIC_EQUIVALENCE, + } + return AnswerContractResult(obligations=obligations, checks=checks, forbidden_checks=forbidden_checks) + + +def _check_obligation( + code: str, + answer: str, + *, + analysis: InputAnalysis, + has_current_interaction: bool, +) -> bool: + if code == "define_dies": + return _has_all(answer, ("muere", "cementerio", "campo de batalla")) or _has_all(answer, ("dies", "graveyard", "battlefield")) + if code == "compare_events": + return any(marker in answer for marker in ("mismo evento", "significa exactamente", "equivale a", "same event", "means exactly")) + if code == "exclude_other_zone_changes": + return ( + any(marker in answer for marker in ("otra zona", "biblioteca", "mano", "exilio", "another zone", "library", "hand", "exile")) + and any(marker in answer for marker in ( + "no muere", + "no cuenta como morir", + "no significa que haya muerto", + "does not die", + "doesn't die", + )) + ) + if code == "apply_undying": + return "undying" in answer and any(marker in answer for marker in ("se dispara", "no se dispara", "triggers", "does not trigger")) + if code == "apply_current_interaction": + if not has_current_interaction: + return True + return "undying" in answer and any( + marker in answer + for marker in ("ozolith", "young wolf", "carrion feeder", "ghave", "ashnod") + ) + if code == "identify_failed_transition": + return any(marker in answer for marker in ("no se dispara", "no vuelve", "se rompe", "does not trigger", "does not return", "breaks")) + if code == "explain_rule": + return any(marker in answer for marker in ("regla", "condicion", "condición", "ultimo estado", "último estado", "rule", "condition", "last battlefield state")) + if code == "list_required_permanents": + return all(name in answer for name in ("young wolf", "ashnod's altar", "ghave")) + if code == "list_initial_state": + return any(marker in answer for marker in ("sin contador", "without a +1/+1 counter")) + if code == "direct_user_question": + return len(answer.split()) >= 5 + return False + + +def _wrong_language(answer: str, *, expected: str) -> bool: + normalized = _normalize(answer) + if expected == "es": + english_markers = sum(marker in normalized for marker in ("the interaction", "does not", "you need", "the loop", "therefore")) + spanish_markers = sum(marker in normalized for marker in ("la interaccion", "no se", "necesitas", "el bucle", "por tanto", "porque")) + return english_markers >= 2 and spanish_markers == 0 + spanish_markers = sum(marker in normalized for marker in ("la interaccion", "necesitas", "el bucle", "por tanto")) + english_markers = sum(marker in normalized for marker in ("the interaction", "you need", "the loop", "therefore")) + return spanish_markers >= 2 and english_markers == 0 + + +def _has_all(text: str, markers: tuple[str, ...]) -> bool: + return all(marker in text for marker in markers) + + +def _normalize(text: str) -> str: + value = unicodedata.normalize("NFKD", text or "") + value = "".join(char for char in value if not unicodedata.combining(char)) + return re.sub(r"\s+", " ", value.casefold()).strip() diff --git a/magicai/tactician/claims.py b/magicai/tactician/claims.py index 8b31a73..98d69fd 100644 --- a/magicai/tactician/claims.py +++ b/magicai/tactician/claims.py @@ -48,76 +48,112 @@ def evaluate_claims( has_ozolith = "the ozolith" in oracle_by_name has_undying = any("undying" in oracle.casefold() for oracle in oracle_by_name.values()) - verdicts: list[ClaimVerdict] = [] - for claim in analysis.claims: - verdicts.append( - _evaluate_claim( - claim, - has_ozolith=has_ozolith, - has_undying=has_undying, - evidence_ids=evidence_ids, - ) + return [ + _evaluate_claim( + claim, + language=analysis.language, + has_ozolith=has_ozolith, + has_undying=has_undying, + evidence_ids=evidence_ids, ) - return verdicts + for claim in analysis.claims + ] def _evaluate_claim( claim: InputClaim, *, + language: str, has_ozolith: bool, has_undying: bool, evidence_ids: set[str], ) -> ClaimVerdict: concepts = set(claim.concepts) + spanish = language == "es" + + if claim.kind is ClaimKind.MECHANIC_EQUIVALENCE: + required = {"700.4", "702.93a"} + if required.issubset(evidence_ids): + explanation = ( + "La idea es parcialmente correcta, pero 'morir' y ser puesta en un cementerio desde el campo de batalla no son eventos distintos: son el mismo evento reglamentario. Undying no se dispara por entrar al cementerio desde cualquier otra zona." + if spanish else + "The idea is partially correct, but dying and being put into a graveyard from the battlefield are not separate events: they are the same rules event. Undying does not trigger for entering a graveyard from another zone." + ) + return ClaimVerdict( + claim.claim_id, + claim.text, + ClaimVerdictStatus.PARTIALLY_SUPPORTED, + explanation, + ("700.4", "702.93a"), + ) if claim.kind is ClaimKind.STATE_TRANSITION and "sacrifice" in concepts: + explanation = ( + "Sacrificar un permanente lo mueve del campo de batalla al cementerio de su propietario; si es una criatura, muere." + if spanish else + "Sacrificing a permanent moves it from the battlefield to its owner's graveyard; a creature moved this way dies." + ) return ClaimVerdict( - claim_id=claim.claim_id, - claim=claim.text, - status=ClaimVerdictStatus.SUPPORTED, - explanation="Sacrificing a permanent moves it from the battlefield to its owner's graveyard; a creature moved this way dies.", - evidence=_prefer_evidence(evidence_ids, "701.21a", "700.4"), + claim.claim_id, + claim.text, + ClaimVerdictStatus.SUPPORTED, + explanation, + _prefer_evidence(evidence_ids, "701.21a", "700.4"), ) if claim.kind is ClaimKind.TIMING_HYPOTHESIS and has_ozolith and "counters" in concepts: + explanation = ( + "The Ozolith no retira ni transfiere el contador original antes del cambio de zona. El contador deja de existir cuando el objeto cambia de zona y la habilidad disparada de The Ozolith pone después contadores equivalentes sobre el artefacto." + if spanish else + "The Ozolith does not remove or transfer the original counter before the zone change. The counter ceases to exist when the object changes zones, and The Ozolith's triggered ability puts corresponding counters on itself later." + ) return ClaimVerdict( - claim_id=claim.claim_id, - claim=claim.text, - status=ClaimVerdictStatus.CONTRADICTED, - explanation=( - "The Ozolith does not remove or transfer the original counter before the zone change. " - "The counter ceases to exist when the object changes zones, and The Ozolith's triggered ability puts corresponding counters on itself later." - ), - evidence=_prefer_evidence(evidence_ids, "122.2", "400.7", "603.6c", "603.10a"), + claim.claim_id, + claim.text, + ClaimVerdictStatus.CONTRADICTED, + explanation, + _prefer_evidence(evidence_ids, "122.2", "400.7", "603.6c", "603.10a"), ) if claim.kind is ClaimKind.DERIVED_CONCLUSION and has_undying: + explanation = ( + "Undying usa el último estado de la criatura en el campo de batalla. Si Young Wolf tenía un contador +1/+1 inmediatamente antes de salir, la condición no se cumple y Undying no se dispara." + if spanish else + "Undying uses the creature's last battlefield state. If Young Wolf had a +1/+1 counter immediately before it left, the condition is false and Undying does not trigger." + ) return ClaimVerdict( - claim_id=claim.claim_id, - claim=claim.text, - status=ClaimVerdictStatus.CONTRADICTED, - explanation=( - "Undying uses the creature's last battlefield state for the leaves-the-battlefield trigger. " - "If Young Wolf had a +1/+1 counter immediately before it left, the intervening-if condition is false and Undying does not trigger." - ), - evidence=_prefer_evidence(evidence_ids, "702.93a", "603.4", "603.10a"), + claim.claim_id, + claim.text, + ClaimVerdictStatus.CONTRADICTED, + explanation, + _prefer_evidence(evidence_ids, "702.93a", "603.4", "603.10a"), ) if claim.kind is ClaimKind.STRATEGIC: + explanation = ( + "La clasificación del combo debe derivarse de las transiciones de estado verificadas y del resultado neto." + if spanish else + "Whether the pieces form a combo must be derived from the verified state transitions and net result." + ) return ClaimVerdict( - claim_id=claim.claim_id, - claim=claim.text, - status=ClaimVerdictStatus.STRATEGIC_OPINION, - explanation="Whether the pieces form a combo must be derived from the verified state transitions and net result.", - evidence=(), + claim.claim_id, + claim.text, + ClaimVerdictStatus.STRATEGIC_OPINION, + explanation, + (), ) + explanation = ( + "La evidencia disponible todavía no permite confirmar ni refutar esta afirmación." + if spanish else + "The current evidence package does not prove or disprove this claim yet." + ) return ClaimVerdict( - claim_id=claim.claim_id, - claim=claim.text, - status=ClaimVerdictStatus.INSUFFICIENT_EVIDENCE, - explanation="The current evidence package does not prove or disprove this claim yet.", - evidence=(), + claim.claim_id, + claim.text, + ClaimVerdictStatus.INSUFFICIENT_EVIDENCE, + explanation, + (), ) diff --git a/magicai/tactician/core.py b/magicai/tactician/core.py index c59c58c..ad8a6ca 100644 --- a/magicai/tactician/core.py +++ b/magicai/tactician/core.py @@ -10,6 +10,11 @@ JudgeToolRequest, JudgeToolStatus, ) +from magicai.language.localization import localize_messages +from magicai.tactician.answer_contract import ( + build_answer_obligations, + validate_answer_contract, +) from magicai.tactician.claims import ( ClaimVerdictStatus, evaluate_claims, @@ -69,7 +74,10 @@ def from_judge_result( strategic response. """ - input_analysis = analyze_user_input(question) + input_analysis = analyze_user_input( + question, + session_language=getattr(conversation, "language", None), + ) judge_payload = judge_result.to_dict() merged_cards, inherited_names = _merge_context_cards( current=judge_payload.get("cards", []), @@ -77,7 +85,16 @@ def from_judge_result( inherit=( looks_like_referential_follow_up(question) or input_analysis.speech_act.value in {"challenge", "follow_up"} - or input_analysis.strategy_intent.value in {"play_sequence", "combo_disruption", "combo_requirements"} + or input_analysis.strategy_intent.value in { + "play_sequence", + "combo_disruption", + "combo_requirements", + "rules_clarification", + "mechanic_definition", + "mechanic_equivalence", + "combo_failure_explanation", + "interaction_timing", + } ), ) @@ -127,21 +144,40 @@ def from_judge_result( claim_verdicts=claim_verdicts, ) + has_current_interaction = len(merged_cards) >= 2 + obligations = build_answer_obligations( + input_analysis, + has_current_interaction=has_current_interaction, + ) + answer_contract = validate_answer_contract( + synthesis.answer, + analysis=input_analysis, + obligations=obligations, + has_current_interaction=has_current_interaction, + ) + judge_status = str(judge_payload.get("status", "")) if judge_status not in {"strategy_required", "answered"}: status = judge_status or "insufficient_evidence" - warnings = [ - *list(judge_payload.get("warnings", [])), - "The Judge could not close the factual package; the Strategist's conclusion is limited to the evidence recovered so far.", - ] + warnings = localize_messages( + [ + *list(judge_payload.get("warnings", [])), + "The Judge could not close the factual package; the Strategist's conclusion is limited to the evidence recovered so far.", + ], + input_analysis.language, + ) else: status = "answered" - warnings = list(judge_payload.get("warnings", [])) + warnings = localize_messages( + list(judge_payload.get("warnings", [])), + input_analysis.language, + ) judge_verified = _judge_verified( claim_verdicts=claim_verdicts, combo_classification=synthesis.combo_classification, tool_calls=tool_calls, + answer_complete=answer_contract.complete, ) confidence = _strategy_confidence( synthesis.combo_classification, @@ -175,9 +211,11 @@ def from_judge_result( authority_trace.append("judge:tool_gateway") authority_trace.extend( [ + "tactician:language_policy", "tactician:input_analysis", "tactician:claim_evaluation", "tactician:strategic_synthesis", + "tactician:answer_contract", "judge:evidence_verification" if judge_verified else "judge:evidence_incomplete", ] ) @@ -213,6 +251,11 @@ def from_judge_result( queries_completed=len(tool_calls), judge_verified=judge_verified, investigation_plan=plan.to_dict(), + response_language=input_analysis.language, + language_policy=dict(input_analysis.language_policy), + answer_obligations=[item.to_dict() for item in obligations], + answer_contract=answer_contract.to_dict(), + answer_complete=answer_contract.complete, ) return result @@ -282,6 +325,7 @@ def _apply_result_state(conversation, result: TacticianResult) -> None: conversation.active_cards = deepcopy(result.cards) conversation.last_intent = result.strategy_intent conversation.mode = "tactician" + conversation.language = result.response_language conversation.strategy_context = { "last_intent": result.strategy_intent, "active_cards": [card.get("name", "") for card in result.cards], @@ -289,6 +333,9 @@ def _apply_result_state(conversation, result: TacticianResult) -> None: "reasoning_summary": list(result.reasoning_summary), "claim_verdicts": deepcopy(result.claim_verdicts), "judge_verified": bool(result.judge_verified), + "response_language": result.response_language, + "answer_complete": bool(result.answer_complete), + "answer_obligations": deepcopy(result.answer_obligations), } @@ -312,6 +359,7 @@ def _judge_verified( claim_verdicts, combo_classification: str, tool_calls: list[dict[str, Any]], + answer_complete: bool, ) -> bool: unresolved = [ verdict @@ -322,7 +370,7 @@ def _judge_verified( call.get("status") == "success" and call.get("evidence") for call in tool_calls ) - if unresolved: + if unresolved or not answer_complete: return False if claim_verdicts: return successful_evidence diff --git a/magicai/tactician/input_analysis.py b/magicai/tactician/input_analysis.py index 15c65f3..af84cfb 100644 --- a/magicai/tactician/input_analysis.py +++ b/magicai/tactician/input_analysis.py @@ -5,7 +5,9 @@ import re import unicodedata -from magicai.tactician.intents import StrategyIntent, classify_strategy_intent, is_spanish +from magicai.conversation.normalization import normalize_user_question +from magicai.language.policy import resolve_language_policy +from magicai.tactician.intents import StrategyIntent, classify_strategy_intent class SpeechAct(str, Enum): @@ -21,6 +23,7 @@ class ClaimKind(str, Enum): STATE_TRANSITION = "state_transition" TIMING_HYPOTHESIS = "timing_hypothesis" DERIVED_CONCLUSION = "derived_conclusion" + MECHANIC_EQUIVALENCE = "mechanic_equivalence" STRATEGIC = "strategic" @@ -44,19 +47,29 @@ def to_dict(self) -> dict[str, object]: class InputAnalysis: raw_text: str normalized_text: str + canonical_text: str language: str + language_policy: dict[str, object] + register: str speech_act: SpeechAct strategy_intent: StrategyIntent claims: list[InputClaim] = field(default_factory=list) concepts: list[str] = field(default_factory=list) causal_markers: list[str] = field(default_factory=list) asks_for_validation: bool = False + question_target: str = "" + answer_focus: list[str] = field(default_factory=list) def to_dict(self) -> dict[str, object]: return { "language": self.language, + "language_policy": dict(self.language_policy), + "register": self.register, + "canonical_question": self.canonical_text, "speech_act": self.speech_act.value, "strategy_intent": self.strategy_intent.value, + "question_target": self.question_target, + "answer_focus": list(self.answer_focus), "claims_detected": len(self.claims), "claims": [claim.to_dict() for claim in self.claims], "concepts": list(self.concepts), @@ -66,71 +79,70 @@ def to_dict(self) -> dict[str, object]: _CAUSAL_MARKERS = ( - "por lo que", - "asi que", - "de modo que", - "entonces", - "therefore", - "so that", - "which means", + "por lo que", "asi que", "de modo que", "entonces", "therefore", "so that", "which means", ) - _VALIDATION_MARKERS = ( - "hace combo", - "es correcto", - "tengo razon", - "tengo razón", - "funciona asi", - "funciona así", - "seria asi", - "sería así", - "is that correct", - "does that work", + "hace combo", "es correcto", "tengo razon", "tengo razón", "funciona asi", "funciona así", + "seria asi", "sería así", "is that correct", "does that work", ) -def analyze_user_input(text: str) -> InputAnalysis: +def analyze_user_input(text: str, *, session_language: str | None = None) -> InputAnalysis: raw = (text or "").strip() - normalized = _normalize(raw) + policy = resolve_language_policy(raw, session_language=session_language) + normalized_question = normalize_user_question(raw, session_language=policy.response_language) + canonical = normalized_question.canonical or raw + normalized = _normalize(canonical) causal_markers = [marker for marker in _CAUSAL_MARKERS if marker in normalized] asks_for_validation = any(marker in normalized for marker in _VALIDATION_MARKERS) if re.match(r"^(pero|but)\b", normalized): speech_act = SpeechAct.CHALLENGE - elif causal_markers and not _looks_like_simple_question(raw, normalized): - speech_act = SpeechAct.HYPOTHESIS elif re.match(r"^(y|and)\b", normalized): speech_act = SpeechAct.FOLLOW_UP - elif _looks_like_simple_question(raw, normalized): + elif _looks_like_question(raw, normalized): speech_act = SpeechAct.QUESTION + elif causal_markers: + speech_act = SpeechAct.HYPOTHESIS else: speech_act = SpeechAct.REQUEST - intent = classify_strategy_intent(raw) + intent = classify_strategy_intent(canonical) concepts = _detect_concepts(normalized) - claims = _extract_claims(raw, normalized, concepts) + claims = _extract_claims(raw, normalized, concepts, speech_act, intent) - if speech_act in {SpeechAct.CHALLENGE, SpeechAct.HYPOTHESIS}: + if speech_act in {SpeechAct.CHALLENGE, SpeechAct.HYPOTHESIS} and intent not in { + StrategyIntent.COMBO_FAILURE_EXPLANATION, + StrategyIntent.MECHANIC_EQUIVALENCE, + StrategyIntent.INTERACTION_TIMING, + }: intent = StrategyIntent.INTERACTION_HYPOTHESIS - elif "orden" in normalized or "sequence" in normalized: - intent = StrategyIntent.PLAY_SEQUENCE - elif any(marker in normalized for marker in ("cortar", "interrump", "disrupt", "stop the combo")): - intent = StrategyIntent.COMBO_DISRUPTION return InputAnalysis( raw_text=raw, normalized_text=normalized, - language="es" if is_spanish(raw) else "en", + canonical_text=canonical, + language=policy.response_language, + language_policy=policy.to_dict(), + register=normalized_question.register, speech_act=speech_act, strategy_intent=intent, claims=claims, concepts=concepts, causal_markers=causal_markers, asks_for_validation=asks_for_validation, + question_target=intent.value, + answer_focus=_answer_focus(intent, concepts), ) -def _extract_claims(raw: str, normalized: str, concepts: list[str]) -> list[InputClaim]: +def _extract_claims( + raw: str, + normalized: str, + concepts: list[str], + speech_act: SpeechAct, + intent: StrategyIntent, +) -> list[InputClaim]: claims: list[InputClaim] = [] def add(text: str, kind: ClaimKind, claim_concepts: tuple[str, ...]) -> None: @@ -139,14 +151,10 @@ def add(text: str, kind: ClaimKind, claim_concepts: tuple[str, ...]) -> None: return if any(existing.text.casefold() == cleaned.casefold() for existing in claims): return - claims.append( - InputClaim( - claim_id=f"claim-{len(claims) + 1}", - text=cleaned, - kind=kind, - concepts=claim_concepts, - ) - ) + claims.append(InputClaim(f"claim-{len(claims) + 1}", cleaned, kind, claim_concepts)) + + if intent is StrategyIntent.MECHANIC_EQUIVALENCE: + add(raw, ClaimKind.MECHANIC_EQUIVALENCE, ("dies", "graveyard", "undying")) if "sacrific" in normalized and any(name in normalized for name in ("young wolf", "criatura", "creature")): add( @@ -168,27 +176,27 @@ def add(text: str, kind: ClaimKind, claim_concepts: tuple[str, ...]) -> None: ) if "undying" in normalized and any(marker in normalized for marker in ("activa", "dispara", "vuelve", "triggers", "returns")): - add( - "Undying triggers again after Young Wolf leaves the battlefield", - ClaimKind.DERIVED_CONCLUSION, - ("undying", "intervening_if", "last_known_information"), - ) + # Do not turn a pure definition/equivalence question into a false claim + # that Undying necessarily triggers. + if intent is not StrategyIntent.MECHANIC_EQUIVALENCE: + add( + "Undying triggers again after Young Wolf leaves the battlefield", + ClaimKind.DERIVED_CONCLUSION, + ("undying", "intervening_if", "last_known_information"), + ) - if "combo" in normalized: - add( - raw, - ClaimKind.STRATEGIC, - tuple(concepts), - ) + if "combo" in normalized and speech_act in {SpeechAct.HYPOTHESIS, SpeechAct.CHALLENGE, SpeechAct.REQUEST}: + add(raw, ClaimKind.STRATEGIC, tuple(concepts)) - if not claims and raw: + # Only assertion-like turns receive generic claim extraction. Open questions + # such as "¿Qué necesito?" are question targets, not factual claims. + if not claims and speech_act in {SpeechAct.CHALLENGE, SpeechAct.HYPOTHESIS}: fragments = re.split(r"[;.]|\b(?:por lo que|asi que|así que|therefore)\b", raw, flags=re.IGNORECASE) for fragment in fragments[:4]: fragment_normalized = _normalize(fragment) if len(fragment_normalized.split()) < 3: continue - kind = ClaimKind.DERIVED_CONCLUSION if any(marker in fragment_normalized for marker in ("por lo que", "asi que", "therefore")) else ClaimKind.FACTUAL - add(fragment, kind, tuple(_detect_concepts(fragment_normalized))) + add(fragment, ClaimKind.FACTUAL, tuple(_detect_concepts(fragment_normalized))) return claims @@ -202,11 +210,14 @@ def _detect_concepts(normalized: str) -> list[str]: ("counters", ("contador", "counter")), ("leaves_battlefield", ("deja el campo", "abandona el campo", "leaves the battlefield")), ("graveyard", ("cementerio", "graveyard")), + ("battlefield", ("campo de batalla", "battlefield")), ("last_known_information", ("antes de dejar", "justo antes", "last known", "cuando deja")), ("ozolith", ("ozolith",)), ("combo", ("combo", "bucle", "loop", "infinito", "infinite")), ("play_sequence", ("orden", "sequence")), ("disruption", ("cortar", "interrump", "disrupt", "stop")), + ("definition", ("que es", "como funciona", "define", "what is", "how does")), + ("equivalence", ("es lo mismo", "equivale", "no cuando", "mismo evento", "same event")), ) for concept, markers in mappings: if any(marker in normalized for marker in markers): @@ -214,15 +225,25 @@ def _detect_concepts(normalized: str) -> list[str]: return concepts -def _looks_like_simple_question(raw: str, normalized: str) -> bool: +def _answer_focus(intent: StrategyIntent, concepts: list[str]) -> list[str]: + if intent is StrategyIntent.MECHANIC_EQUIVALENCE: + return ["define_dies", "compare_events", "exclude_other_zone_changes", "apply_current_interaction"] + if intent is StrategyIntent.COMBO_FAILURE_EXPLANATION: + return ["identify_failed_transition", "explain_rule", "apply_current_interaction"] + if intent is StrategyIntent.INTERACTION_TIMING: + return ["identify_timing_window", "explain_state_check", "apply_current_interaction"] + if intent is StrategyIntent.COMBO_REQUIREMENTS: + return ["list_required_permanents", "list_initial_state", "list_disruptive_assumptions"] + return list(concepts) + + +def _looks_like_question(raw: str, normalized: str) -> bool: if "?" in raw or "¿" in raw: return True - return bool( - re.match( - r"^(que|qué|como|cómo|cual|cuál|cuando|cuándo|donde|dónde|por que|por qué|puedo|tiene|hace|is|does|how|what|when|where|why|can)\b", - normalized, - ) - ) + return bool(re.match( + r"^(que|qué|como|cómo|cual|cuál|cuando|cuándo|donde|dónde|por que|por qué|puedo|tiene|hace|is|does|how|what|when|where|why|can|entonces)\b", + normalized, + )) def _normalize(text: str) -> str: diff --git a/magicai/tactician/intents.py b/magicai/tactician/intents.py index d781507..b90419d 100644 --- a/magicai/tactician/intents.py +++ b/magicai/tactician/intents.py @@ -4,6 +4,8 @@ import unicodedata from enum import Enum +from magicai.language.detection import detect_language + class StrategyIntent(str, Enum): COMBO_DETECTION = "combo_detection" @@ -15,101 +17,72 @@ class StrategyIntent(str, Enum): COMBO_DISRUPTION = "combo_disruption" COMBO_REQUIREMENTS = "combo_requirements" INTERACTION_HYPOTHESIS = "interaction_hypothesis" + RULES_CLARIFICATION = "rules_clarification" + MECHANIC_DEFINITION = "mechanic_definition" + MECHANIC_EQUIVALENCE = "mechanic_equivalence" + COMBO_FAILURE_EXPLANATION = "combo_failure_explanation" + INTERACTION_TIMING = "interaction_timing" GENERAL_STRATEGY = "general_strategy" _COMBO_MARKERS = ( - "combo", - "infinito", - "infinite", - "loop", - "bucle", + "combo", "infinito", "infinite", "loop", "bucle", ) - _SYNERGY_MARKERS = ( - "sinergia", - "synergy", - "funcionan juntas", - "funcionan juntos", - "interactuan", - "interactúan", + "sinergia", "synergy", "funcionan juntas", "funcionan juntos", "interactuan", "interactúan", ) - _COMPARISON_MARKERS = ( - "cual es mejor", - "cuál es mejor", - "mejor que", - "compar", - "versus", - " vs ", - "which is better", - "better than", + "cual es mejor", "cuál es mejor", "mejor que", "compar", "versus", " vs ", "which is better", "better than", ) - _PLAY_MARKERS = ( - "que juego", - "qué juego", - "que jugada", - "qué jugada", - "linea de juego", - "línea de juego", - "deberia hacer", - "debería hacer", - "conviene", - "play line", - "what should i do", + "que juego", "qué juego", "que jugada", "qué jugada", "linea de juego", "línea de juego", + "deberia hacer", "debería hacer", "conviene", "play line", "what should i do", ) - _SEQUENCE_MARKERS = ( - "en que orden", - "en qué orden", - "orden se juega", - "orden del combo", - "play sequence", - "what order", + "en que orden", "en qué orden", "orden se juega", "orden del combo", "play sequence", "what order", ) - _DISRUPTION_MARKERS = ( - "donde se corta", - "dónde se corta", - "como se corta", - "cómo se corta", - "interrump", - "disrupt", - "stop the combo", + "donde se corta", "dónde se corta", "como se corta", "cómo se corta", "cortar", "cortarlo", "interrump", "disrupt", "stop the combo", ) - _REQUIREMENT_MARKERS = ( - "que necesito", - "qué necesito", - "requisitos", - "required pieces", - "what do i need", + "que necesito", "qué necesito", "requisitos", "required pieces", "what do i need", ) - _HYPOTHESIS_MARKERS = ( - "por lo que", - "asi que", - "así que", - "entonces se", - "therefore", - "which means", + "por lo que", "asi que", "así que", "entonces se", "therefore", "which means", ) - _DECK_MARKERS = ( - "mi mazo", - "decklist", - "deck list", - "construir mazo", - "mejorar mazo", - "deckbuilding", - "upgrade", + "mi mazo", "decklist", "deck list", "construir mazo", "mejorar mazo", "deckbuilding", "upgrade", +) +_FAILURE_MARKERS = ( + "por que no", "por qué no", "porque no", "que impide", "qué impide", "donde falla", "dónde falla", + "por que se rompe", "por qué se rompe", "why does not", "why doesn't", "why does the combo fail", +) +_TIMING_MARKERS = ( + "en que momento", "en qué momento", "cuando comprueba", "cuándo comprueba", "antes o despues", "antes o después", + "when does", "at what point", "timing", +) +_EQUIVALENCE_MARKERS = ( + "es cuando muere", "no cuando", "es lo mismo", "mismo evento", "equivale", "significa lo mismo", + "same event", "is the same as", "rather than", +) +_DEFINITION_MARKERS = ( + "que es", "qué es", "define", "como funciona", "cómo funciona", "what is", "define", "how does", +) +_RULES_CLARIFICATION_MARKERS = ( + "regla", "rules", "se dispara", "se activa", "trigger", "cementerio", "graveyard", "muere", "dies", + "prioridad", "priority", "pila", "stack", ) def classify_strategy_intent(question: str) -> StrategyIntent: normalized = _normalize(question) + if _looks_like_mechanic_equivalence(normalized): + return StrategyIntent.MECHANIC_EQUIVALENCE + if any(marker in normalized for marker in _FAILURE_MARKERS): + return StrategyIntent.COMBO_FAILURE_EXPLANATION + if any(marker in normalized for marker in _TIMING_MARKERS): + return StrategyIntent.INTERACTION_TIMING if re.match(r"^(pero|but)\b", normalized) or any(marker in normalized for marker in _HYPOTHESIS_MARKERS): return StrategyIntent.INTERACTION_HYPOTHESIS if any(marker in normalized for marker in _SEQUENCE_MARKERS): @@ -128,53 +101,42 @@ def classify_strategy_intent(question: str) -> StrategyIntent: return StrategyIntent.PLAY_RECOMMENDATION if any(marker in normalized for marker in _DECK_MARKERS): return StrategyIntent.DECKBUILDING + if any(marker in normalized for marker in _DEFINITION_MARKERS) and any( + keyword in normalized for keyword in ("undying", "persist", "ward", "habilidad", "ability") + ): + return StrategyIntent.MECHANIC_DEFINITION + if any(marker in normalized for marker in _RULES_CLARIFICATION_MARKERS): + return StrategyIntent.RULES_CLARIFICATION return StrategyIntent.GENERAL_STRATEGY def looks_like_referential_follow_up(question: str) -> bool: normalized = " " + _normalize(question) + " " - if re.match(r"^\s*[¿?¡!]*(?:y|and)\b", normalized): + if re.match(r"^\s*[¿?¡!]*(?:y|and|entonces|so)\b", normalized): return True - if re.search( - r"\b(?:lo|la|los|las|ello|ellos|ellas|it|them|that|those)\b", - normalized, - ): + if re.search(r"\b(?:lo|la|los|las|ello|ellos|ellas|it|them|that|those|eso|esto)\b", normalized): return True return any( marker in normalized - for marker in ( - " tambien ", - " también ", - " ademas ", - " además ", - " with them ", - " con ellos ", - " con ellas ", - ) + for marker in (" tambien ", " también ", " ademas ", " además ", " with them ", " con ellos ", " con ellas ") ) def is_spanish(question: str) -> bool: - if "¿" in (question or "") or "¡" in (question or ""): - return True - normalized = _normalize(question) - spanish_markers = ( - "que ", - "qué ", - "como ", - "cómo ", - "con ", - "tiene ", - "mazo", - "jugada", - "sinergia", - "puedo", + language, _, _, _ = detect_language(question, default="es") + return language == "es" + + +def _looks_like_mechanic_equivalence(normalized: str) -> bool: + has_mechanic = any(keyword in normalized for keyword in ("undying", "persist", "ward", "habilidad", "ability")) + has_event_pair = ( + any(marker in normalized for marker in ("muere", "morir", "dies")) + and any(marker in normalized for marker in ("cementerio", "graveyard")) ) - return any(marker in normalized for marker in spanish_markers) + return has_mechanic and has_event_pair and any(marker in normalized for marker in _EQUIVALENCE_MARKERS) def _normalize(text: str) -> str: value = unicodedata.normalize("NFKD", text or "") value = "".join(char for char in value if not unicodedata.combining(char)) - value = re.sub(r"\s+", " ", value.casefold()).strip() - return value + return re.sub(r"\s+", " ", value.casefold()).strip() diff --git a/magicai/tactician/models.py b/magicai/tactician/models.py index a297ce3..33db735 100644 --- a/magicai/tactician/models.py +++ b/magicai/tactician/models.py @@ -84,6 +84,11 @@ class TacticianResult: queries_completed: int = 0 judge_verified: bool = False investigation_plan: dict[str, Any] = field(default_factory=dict) + response_language: str = "es" + language_policy: dict[str, Any] = field(default_factory=dict) + answer_obligations: list[dict[str, Any]] = field(default_factory=list) + answer_contract: dict[str, Any] = field(default_factory=dict) + answer_complete: bool = False def to_dict(self) -> dict[str, Any]: return { @@ -126,5 +131,10 @@ def to_dict(self) -> dict[str, Any]: "queries_completed": int(self.queries_completed), "judge_verified": bool(self.judge_verified), "investigation_plan": dict(self.investigation_plan), + "response_language": self.response_language, + "language_policy": dict(self.language_policy), + "answer_obligations": list(self.answer_obligations), + "answer_contract": dict(self.answer_contract), + "answer_complete": bool(self.answer_complete), "judge_result": dict(self.judge_result), } diff --git a/magicai/tactician/planner.py b/magicai/tactician/planner.py index fbdc1d6..d5f0445 100644 --- a/magicai/tactician/planner.py +++ b/magicai/tactician/planner.py @@ -5,6 +5,7 @@ from magicai.judge_tools import JudgeToolRequest from magicai.tactician.input_analysis import InputAnalysis +from magicai.tactician.intents import StrategyIntent @dataclass(slots=True) @@ -29,53 +30,112 @@ def plan_investigation( requests: list[JudgeToolRequest] = [] goals: list[str] = [] names = [str(card.get("name", "")).strip() for card in cards if card.get("name")] + spanish = analysis.language == "es" if names and not oracle_already_refreshed: - requests.append( - JudgeToolRequest( - tool="oracle_lookup", - arguments={"card_names": names}, - purpose="verify_input_cards", - result_limit=min(max(len(names), 1), 20), - ) + requests.append(JudgeToolRequest( + tool="oracle_lookup", + arguments={"card_names": names}, + purpose="verify_input_cards", + result_limit=min(max(len(names), 1), 20), + )) + goals.append( + "Verificar el texto Oracle actual de cada carta implicada." + if spanish else + "Verify the current Oracle text for every referenced card." ) - goals.append("Verify the current Oracle text for every referenced card.") rules: list[str] = [] concepts = set(analysis.concepts) - if "sacrifice" in concepts or "dies" in concepts: - rules.extend(["701.21a", "700.4"]) - goals.append("Verify the sacrifice-to-dies transition.") - if "undying" in concepts or any("Undying" in str(card.get("oracle_text", "")) for card in cards): - rules.extend(["702.93a", "603.4"]) - goals.append("Verify the Undying trigger condition.") + intent = analysis.strategy_intent + + if intent is StrategyIntent.MECHANIC_EQUIVALENCE: + rules.extend(["700.4", "702.93a", "603.4"]) + goals.extend([ + "Definir reglamentariamente qué significa que una criatura muera." if spanish else "Define the rules meaning of a creature dying.", + "Relacionar el evento de morir con Undying y excluir entradas al cementerio desde otras zonas." if spanish else "Relate dying to Undying and exclude graveyard entry from other zones.", + ]) + else: + if "sacrifice" in concepts or "dies" in concepts: + rules.extend(["701.21a", "700.4"]) + goals.append( + "Verificar la transición de sacrificar a morir." + if spanish else + "Verify the sacrifice-to-dies transition." + ) + if "undying" in concepts or any("Undying" in str(card.get("oracle_text", "")) for card in cards): + rules.extend(["702.93a", "603.4"]) + goals.append( + "Verificar la condición de disparo de Undying." + if spanish else + "Verify the Undying trigger condition." + ) + + if intent in {StrategyIntent.INTERACTION_TIMING, StrategyIntent.COMBO_FAILURE_EXPLANATION}: + rules.extend(["603.4", "603.6c", "603.10a", "400.7"]) + goals.append( + "Identificar el momento exacto en que se comprueba el estado anterior del permanente." + if spanish else + "Identify the exact point at which the permanent's previous state is checked." + ) + if {"counters", "ozolith", "leaves_battlefield", "last_known_information"} & concepts: rules.extend(["122.2", "400.7", "603.6c", "603.10a"]) - goals.append("Verify counter persistence and leaves-the-battlefield timing.") + goals.append( + "Verificar la persistencia de contadores y el momento de las habilidades de salida del campo." + if spanish else + "Verify counter persistence and leaves-the-battlefield timing." + ) rules = _deduplicate(rules) if rules: - requests.append( - JudgeToolRequest( - tool="rules_lookup", - arguments={"identifiers": rules}, - purpose="verify_claim_timing_and_state", - result_limit=min(len(rules), 20), - ) - ) + requests.append(JudgeToolRequest( + tool="rules_lookup", + arguments={"identifiers": rules}, + purpose="verify_claim_timing_and_state", + result_limit=min(len(rules), 20), + )) - if any(name.casefold() == "the ozolith" for name in names): - requests.append( - JudgeToolRequest( - tool="rulings_lookup", - arguments={"card_names": ["The Ozolith"]}, - purpose="check_ozolith_rulings", - result_limit=8, - ) + # Rulings are useful when The Ozolith itself is part of the current claim, + # but not for a generic definition question that merely inherits the card. + needs_ozolith_ruling = ( + any(name.casefold() == "the ozolith" for name in names) + and ( + "ozolith" in concepts + or intent in { + StrategyIntent.INTERACTION_HYPOTHESIS, + StrategyIntent.COMBO_FAILURE_EXPLANATION, + StrategyIntent.INTERACTION_TIMING, + StrategyIntent.COMBO_DETECTION, + } + ) + ) + if needs_ozolith_ruling: + requests.append(JudgeToolRequest( + tool="rulings_lookup", + arguments={"card_names": ["The Ozolith"]}, + purpose="check_ozolith_rulings", + result_limit=8, + )) + goals.append( + "Comprobar las aclaraciones oficiales de The Ozolith." + if spanish else + "Check official rulings for The Ozolith." ) - goals.append("Check official rulings for The Ozolith when available.") - return InvestigationPlan(requests=requests, goals=_deduplicate(goals)) + return InvestigationPlan(requests=requested_unique(requests), goals=_deduplicate(goals)) + + +def requested_unique(requests: list[JudgeToolRequest]) -> list[JudgeToolRequest]: + result: list[JudgeToolRequest] = [] + seen: set[tuple[str, str]] = set() + for request in requests: + key = (request.tool, repr(sorted(request.arguments.items()))) + if key in seen: + continue + seen.add(key) + result.append(request) + return result def _deduplicate(items: list[str]) -> list[str]: diff --git a/magicai/tactician/synthesis.py b/magicai/tactician/synthesis.py index 5039477..e9e6468 100644 --- a/magicai/tactician/synthesis.py +++ b/magicai/tactician/synthesis.py @@ -3,7 +3,7 @@ from dataclasses import dataclass, field from typing import Any -from magicai.tactician.claims import ClaimVerdict, ClaimVerdictStatus +from magicai.tactician.claims import ClaimVerdict from magicai.tactician.input_analysis import InputAnalysis, SpeechAct from magicai.tactician.intents import StrategyIntent from magicai.tactician.strategy import StrategyAnalysis, analyze_strategy @@ -30,7 +30,17 @@ def synthesize_strategy( cards = list(judge_payload.get("cards", [])) names = {str(card.get("name", "")).casefold() for card in cards} - if {"young wolf", "carrion feeder", "the ozolith"}.issubset(names): + if input_analysis.strategy_intent is StrategyIntent.MECHANIC_EQUIVALENCE: + return _synthesize_undying_dies_equivalence(input_analysis, names) + + if { + "young wolf", "carrion feeder", "the ozolith", + }.issubset(names): + if input_analysis.strategy_intent in { + StrategyIntent.COMBO_FAILURE_EXPLANATION, + StrategyIntent.INTERACTION_TIMING, + }: + return _synthesize_ozolith_failure(input_analysis) return _synthesize_ozolith_interaction(input_analysis, claim_verdicts) if {"young wolf", "ashnod's altar", "ghave, guru of spores"}.issubset(names): @@ -60,6 +70,128 @@ def synthesize_strategy( ) +def _synthesize_undying_dies_equivalence( + input_analysis: InputAnalysis, + names: set[str], +) -> StrategicSynthesis: + spanish = input_analysis.language == "es" + has_ozolith_context = { + "young wolf", "carrion feeder", "the ozolith", + }.issubset(names) + + if spanish: + answer = ( + "En realidad, en Magic no son dos momentos distintos: una criatura muere exactamente cuando es puesta en un cementerio desde el campo de batalla. " + "Undying se dispara por ese mismo evento y, además, comprueba si el permanente tenía contadores +1/+1 justo antes de abandonar el campo. " + "Que una carta llegue al cementerio desde otra zona —por ejemplo, desde la mano o la biblioteca— no significa que haya muerto." + ) + if has_ozolith_context: + answer += ( + " En la interacción con Young Wolf, Carrion Feeder y The Ozolith, el lobo deja el campo todavía con su contador; por eso Undying no se dispara. " + "The Ozolith pone después sobre sí mismo contadores equivalentes, pero no le quitó el contador al lobo antes de esa comprobación." + ) + steps = [ + "Young Wolf pasa del campo de batalla al cementerio: ese movimiento es lo que las reglas llaman morir.", + "Undying comprueba el evento y el último estado de Young Wolf en el campo de batalla.", + "Si tenía un contador +1/+1, la condición de Undying no se cumple y la habilidad no se dispara.", + ] + if has_ozolith_context: + steps.append("The Ozolith se dispara por separado y crea después contadores equivalentes sobre sí mismo.") + outcomes = [ + "Morir y ser puesta en un cementerio desde el campo de batalla son el mismo evento reglamentario.", + "Entrar al cementerio desde otra zona no cuenta como morir.", + ] + summary = [ + "La regla 700.4 define morir como pasar del campo de batalla al cementerio.", + "Undying usa ese evento y comprueba la ausencia de contadores +1/+1.", + ] + risks = [ + "No debe confundirse cualquier entrada al cementerio con morir: la zona de origen importa.", + ] + else: + answer = ( + "They are not two separate moments in Magic: a creature dies exactly when it is put into a graveyard from the battlefield. " + "Undying triggers from that same event and also checks whether the permanent had any +1/+1 counters immediately before it left the battlefield. " + "A card entering a graveyard from another zone, such as a hand or library, did not die." + ) + if has_ozolith_context: + answer += ( + " In the Young Wolf, Carrion Feeder, and The Ozolith interaction, the Wolf leaves with its counter, so Undying does not trigger. " + "The Ozolith puts corresponding counters on itself later; it did not remove the Wolf's counter before that check." + ) + steps = [ + "Young Wolf moves from the battlefield to the graveyard; that movement is what the rules call dying.", + "Undying checks the event and Young Wolf's last battlefield state.", + "If it had a +1/+1 counter, Undying's condition is false and the ability does not trigger.", + ] + if has_ozolith_context: + steps.append("The Ozolith triggers separately and later puts corresponding counters on itself.") + outcomes = [ + "Dying and being put into a graveyard from the battlefield are the same rules event.", + "Entering a graveyard from another zone is not dying.", + ] + summary = [ + "Rule 700.4 defines dying as moving from the battlefield to a graveyard.", + "Undying uses that event and checks for +1/+1 counters.", + ] + risks = [ + "Do not treat every graveyard entry as dying; the origin zone matters.", + ] + + return StrategicSynthesis( + answer=answer, + combo_classification="non_combo" if has_ozolith_context else "not_applicable", + synergies=[], + risks=risks, + combo_steps=steps, + outcomes=outcomes, + reasoning_summary=summary, + ) + + +def _synthesize_ozolith_failure(input_analysis: InputAnalysis) -> StrategicSynthesis: + spanish = input_analysis.language == "es" + if spanish: + answer = ( + "El bucle se rompe en la segunda muerte de Young Wolf. Tras regresar por Undying, el lobo tiene un contador +1/+1. " + "Cuando Carrion Feeder lo sacrifica otra vez, Undying comprueba cómo estaba justo antes de dejar el campo; como tenía ese contador, no se dispara. " + "The Ozolith no evita este resultado: su habilidad se dispara por separado y pone después contadores equivalentes sobre el artefacto." + ) + steps = [ + "Young Wolf regresa una primera vez con un contador +1/+1.", + "Carrion Feeder lo sacrifica de nuevo.", + "Undying comprueba el último estado del lobo y ve el contador +1/+1.", + "La condición falla; Young Wolf no regresa y el bucle termina.", + ] + risks = ["The Ozolith no retira el contador antes de la comprobación de Undying."] + outcomes = ["La interacción genera valor, pero no un bucle infinito."] + summary = ["El paso fallido es el segundo disparo de Undying: la condición no se cumple."] + else: + answer = ( + "The loop breaks on Young Wolf's second death. After returning through Undying, the Wolf has a +1/+1 counter. " + "When Carrion Feeder sacrifices it again, Undying checks its state immediately before it left; because it had that counter, the ability does not trigger. " + "The Ozolith does not change this result: it triggers separately and later puts corresponding counters on itself." + ) + steps = [ + "Young Wolf returns once with a +1/+1 counter.", + "Carrion Feeder sacrifices it again.", + "Undying checks the Wolf's last battlefield state and sees the counter.", + "The condition fails; Young Wolf does not return and the loop ends.", + ] + risks = ["The Ozolith does not remove the counter before Undying checks."] + outcomes = ["The interaction creates value but not an infinite loop."] + summary = ["The failed transition is the second Undying trigger: its condition is false."] + + return StrategicSynthesis( + answer=answer, + combo_classification="non_combo", + synergies=[], + risks=risks, + combo_steps=steps, + outcomes=outcomes, + reasoning_summary=summary, + ) + def _synthesize_ghave_loop(input_analysis: InputAnalysis) -> StrategicSynthesis | None: spanish = input_analysis.language == "es" @@ -80,77 +212,70 @@ def _synthesize_ghave_loop(input_analysis: InputAnalysis) -> StrategicSynthesis if intent is StrategyIntent.PLAY_SEQUENCE: if spanish: answer = ( - "Sí, aquí conviene separar el orden de despliegue del orden del combo. " - "Normalmente bajaría primero Young Wolf, después Ashnod's Altar y dejaría Ghave para el final, " - "porque es la pieza más cara y la que más interesa proteger. Con las tres piezas en mesa, sigue esta secuencia: " + "Conviene separar el orden de despliegue del orden del combo. Normalmente bajaría primero Young Wolf, después Ashnod's Altar y dejaría Ghave para el final, " + "porque es la pieza más cara y la que más interesa proteger. Con las tres piezas en mesa: " + " ".join(f"{index}. {step}" for index, step in enumerate(base_steps_es, start=1)) + " Cada vuelta deja un maná incoloro neto y un Saproling." ) + synergies = ["Ghave retira el contador que bloquearía Undying, mientras Ashnod's Altar paga la activación y deja maná neto."] + risks = ["Evita exponer Ghave antes de tiempo si los rivales conservan removal."] + outcomes = ["Un maná incoloro neto por vuelta.", "Un Saproling por vuelta."] + summary = ["El ciclo devuelve Young Wolf al estado sin contador y paga su propio coste."] else: answer = ( - "It helps to separate deployment order from combo order. I would usually deploy Young Wolf first, " - "then Ashnod's Altar, and keep Ghave for last because it is the most expensive and exposed piece. " - "Once all three are in play: " + "It helps to separate deployment order from combo order. I would usually deploy Young Wolf first, then Ashnod's Altar, and keep Ghave for last because it is the most expensive and exposed piece. Once all three are in play: " + " ".join(f"{index}. {step}" for index, step in enumerate(base_steps_en, start=1)) + " Each iteration produces one net colorless mana and one Saproling." ) - return StrategicSynthesis( - answer=answer, - combo_classification="infinite_combo", - synergies=["Ghave removes the counter that would stop Undying, while Ashnod's Altar pays for Ghave and leaves net mana."], - risks=["Do not expose Ghave earlier than necessary if opponents are holding removal."], - combo_steps=base_steps_es if spanish else base_steps_en, - outcomes=["One net colorless mana per iteration", "One Saproling per iteration"], - reasoning_summary=["The loop restores Young Wolf to the no-counter state and pays its own activation cost."], - ) + synergies = ["Ghave removes the counter that would stop Undying, while Ashnod's Altar pays for Ghave and leaves net mana."] + risks = ["Do not expose Ghave earlier than necessary if opponents are holding removal."] + outcomes = ["One net colorless mana per iteration.", "One Saproling per iteration."] + summary = ["The loop restores Young Wolf to the no-counter state and pays its own activation cost."] + return StrategicSynthesis(answer, "infinite_combo", synergies, risks, base_steps_es if spanish else base_steps_en, outcomes, summary) if intent is StrategyIntent.COMBO_DISRUPTION: if spanish: answer = ( - "El combo tiene varios puntos donde pueden cortártelo. El más claro es responder a Undying y exiliar Young Wolf del cementerio antes de que vuelva. " - "También pueden eliminar Ghave antes de que retire el contador, destruir o anular Ashnod's Altar antes de iniciar la línea, " - "o usar un efecto de reemplazo como Rest in Peace para impedir que Young Wolf llegue al cementerio. " - "Una vez activas el Altar, el sacrificio ya está pagado y esa activación de maná no usa la pila, así que la interacción suele centrarse en Undying o en Ghave." + "El combo tiene varios puntos de interrupción. El más claro es responder a Undying y exiliar Young Wolf del cementerio antes de que vuelva. " + "También pueden eliminar Ghave antes de que retire el contador, desactivar Ashnod's Altar antes de iniciar la línea o usar un efecto de reemplazo como Rest in Peace. " + "El sacrificio del Altar es un coste y su habilidad de maná no usa la pila; por eso la interacción suele centrarse en Undying o en Ghave." ) + risks = ["Exilio del cementerio en respuesta a Undying.", "Removal sobre Ghave.", "Efectos de reemplazo del cementerio."] + outcomes = ["El bucle se detiene si Young Wolf no puede volver o si no puede retirarse su contador."] + summary = ["Los puntos vulnerables son el disparo de Undying, Young Wolf en el cementerio y la habilidad de Ghave."] else: answer = ( - "There are several clean interruption points. An opponent can respond to Undying and exile Young Wolf from the graveyard before it returns, " - "remove Ghave before it clears the counter, disable Ashnod's Altar before the line begins, or use a replacement effect such as Rest in Peace. " - "Once the Altar ability is activated, the sacrifice is already paid and the mana ability does not use the stack, so the main windows are Undying and Ghave." + "There are several clean interruption points. An opponent can respond to Undying and exile Young Wolf from the graveyard before it returns, remove Ghave before it clears the counter, disable Ashnod's Altar before the line begins, or use a replacement effect such as Rest in Peace. " + "The Altar sacrifice is a cost and its mana ability does not use the stack, so the main windows are Undying and Ghave." ) - return StrategicSynthesis( - answer=answer, - combo_classification="infinite_combo", - risks=["Graveyard exile in response to Undying", "Removal on Ghave", "Graveyard replacement effects"], - combo_steps=base_steps_es if spanish else base_steps_en, - outcomes=["The loop stops if Young Wolf cannot return or its counter cannot be removed."], - reasoning_summary=["The vulnerable objects are the Undying trigger, Young Wolf in the graveyard, and Ghave's counter-removal ability."], - ) + risks = ["Graveyard exile in response to Undying.", "Removal on Ghave.", "Graveyard replacement effects."] + outcomes = ["The loop stops if Young Wolf cannot return or its counter cannot be removed."] + summary = ["The vulnerable objects are the Undying trigger, Young Wolf in the graveyard, and Ghave's ability."] + return StrategicSynthesis(answer, "infinite_combo", [], risks, base_steps_es if spanish else base_steps_en, outcomes, summary) if intent is StrategyIntent.COMBO_REQUIREMENTS: if spanish: answer = ( - "Necesitas las tres piezas en el campo, Young Wolf sin contador +1/+1 y al menos la prioridad para iniciar el sacrificio. " + "Necesitas Young Wolf, Ashnod's Altar y Ghave, Guru of Spores en el campo, con Young Wolf sin contador +1/+1 y prioridad para iniciar el sacrificio. " "No necesitas maná inicial si empiezas con Ashnod's Altar: el primer sacrificio genera dos manás, uno paga la habilidad de Ghave y el otro queda como beneficio neto. " - "La línea también presupone que nada reemplaza el cementerio y que los rivales no interrumpen Undying." + "La línea presupone que nada reemplaza el cementerio y que los rivales no interrumpen Undying." ) + risks = ["Young Wolf debe comenzar sin contador +1/+1.", "Un efecto de reemplazo del cementerio impide el bucle."] + outcomes = ["El ciclo se autofinancia desde el primer sacrificio."] + summary = ["Ashnod's Altar produce dos manás y Ghave consume uno, dejando un maná neto mientras reinicia Undying."] else: answer = ( - "You need all three permanents in play, Young Wolf without a +1/+1 counter, and priority to start the sacrifice. " - "No starting mana is required if Ashnod's Altar begins the loop: the first sacrifice makes two mana, one pays Ghave, and one remains net. " - "The line also assumes no graveyard replacement effect and no interruption of Undying." + "You need Young Wolf, Ashnod's Altar, and Ghave, Guru of Spores on the battlefield, with Young Wolf free of +1/+1 counters and priority to start the sacrifice. " + "No starting mana is required: the first Altar sacrifice makes two mana, one pays Ghave, and one remains net. The line also assumes no graveyard replacement effect and no interruption of Undying." ) - return StrategicSynthesis( - answer=answer, - combo_classification="infinite_combo", - risks=["Young Wolf must begin without a +1/+1 counter.", "A graveyard replacement effect prevents the loop."], - combo_steps=base_steps_es if spanish else base_steps_en, - outcomes=["The loop is self-funding after the first sacrifice."], - reasoning_summary=["Ashnod's Altar produces two mana and Ghave consumes one, leaving one net mana while resetting Undying."], - ) + risks = ["Young Wolf must begin without a +1/+1 counter.", "A graveyard replacement effect prevents the loop."] + outcomes = ["The loop is self-funding from the first sacrifice."] + summary = ["Ashnod's Altar produces two mana and Ghave consumes one, leaving one net mana while resetting Undying."] + return StrategicSynthesis(answer, "infinite_combo", [], risks, base_steps_es if spanish else base_steps_en, outcomes, summary) return None + def _synthesize_ozolith_interaction( input_analysis: InputAnalysis, verdicts: list[ClaimVerdict], @@ -161,87 +286,42 @@ def _synthesize_ozolith_interaction( if spanish: opening = ( "Entiendo por qué lo interpretas así, pero la clave está en el momento exacto en que Undying comprueba el contador." - if challenged - else "La interacción tiene valor, pero The Ozolith no reinicia Undying." + if challenged else + "La interacción tiene valor, pero The Ozolith no reinicia Undying." ) steps = [ "Carrion Feeder sacrifica Young Wolf; si el lobo tenía un contador +1/+1, deja el campo con ese contador.", "Undying mira cómo era Young Wolf inmediatamente antes de abandonar el campo. Como tenía un contador +1/+1, Undying no se dispara.", "El contador original deja de existir cuando Young Wolf cambia de zona.", - "The Ozolith se dispara por separado y, cuando su habilidad se resuelve, pone sobre sí mismo un contador equivalente; no retiró el contador del lobo antes de la comprobación de Undying.", + "The Ozolith se dispara por separado y, cuando su habilidad se resuelve, pone sobre sí mismo un contador equivalente.", ] answer = ( - f"{opening} " - "Por eso, Young Wolf, Carrion Feeder y The Ozolith no forman un bucle infinito por sí solos. " - "Carrion Feeder sí obtiene su contador y The Ozolith conserva valor para un combate posterior, " - "pero Young Wolf permanece en el cementerio después del segundo sacrificio." + f"{opening} Por eso, Young Wolf, Carrion Feeder y The Ozolith no forman un bucle infinito por sí solos. " + "Carrion Feeder obtiene su contador y The Ozolith conserva valor para un combate posterior, pero Young Wolf permanece en el cementerio después del segundo sacrificio." ) - synergies = [ - "Carrion Feeder convierte Young Wolf en valor de sacrificio y The Ozolith conserva una copia de los contadores de la criatura que salió.", - ] - risks = [ - "The Ozolith no elimina el contador de Young Wolf antes de que Undying compruebe su condición.", - "Para repetir el ciclo hace falta una pieza que quite o neutralice el contador +1/+1 mientras Young Wolf sigue en el campo de batalla.", - ] - outcomes = [ - "Carrion Feeder recibe un contador +1/+1 por el sacrificio.", - "The Ozolith puede recibir un contador equivalente cuando se resuelva su habilidad.", - "Young Wolf no regresa mediante Undying si tenía un contador +1/+1 al dejar el campo.", - ] - summary = [ - "Young Wolf abandona el campo con el contador +1/+1 todavía sobre él.", - "Undying usa esa última información del campo de batalla y no se dispara.", - "The Ozolith crea contadores sobre sí mismo después; no mueve el contador a tiempo para cambiar la comprobación de Undying.", - ] + synergies = ["Carrion Feeder convierte Young Wolf en valor de sacrificio y The Ozolith conserva contadores equivalentes para un combate posterior."] + risks = ["The Ozolith no elimina el contador de Young Wolf antes de que Undying compruebe su condición.", "Hace falta otra pieza que quite o neutralice el contador mientras Young Wolf sigue en el campo."] + outcomes = ["Carrion Feeder recibe un contador +1/+1.", "The Ozolith puede recibir un contador equivalente.", "Young Wolf no regresa si tenía un contador al dejar el campo."] + summary = ["Young Wolf abandona el campo con el contador todavía sobre él.", "Undying usa ese último estado y no se dispara.", "The Ozolith crea después contadores equivalentes sobre sí mismo."] else: - opening = ( - "I see why that line looks plausible, but the key is when Undying checks the counter." - if challenged - else "The interaction creates value, but The Ozolith does not reset Undying." - ) + opening = "I see why that line looks plausible, but the key is when Undying checks the counter." if challenged else "The interaction creates value, but The Ozolith does not reset Undying." steps = [ - "Carrion Feeder sacrifices Young Wolf; if the Wolf had a +1/+1 counter, it leaves the battlefield with that counter.", - "Undying checks Young Wolf's last battlefield state. Because it had a +1/+1 counter, Undying does not trigger.", + "Carrion Feeder sacrifices Young Wolf; if the Wolf had a +1/+1 counter, it leaves with that counter.", + "Undying checks Young Wolf's last battlefield state. Because it had a counter, Undying does not trigger.", "The original counter ceases to exist when Young Wolf changes zones.", - "The Ozolith triggers separately and later puts a corresponding counter on itself; it did not remove the Wolf's counter before Undying checked.", - ] - answer = ( - f"{opening} Young Wolf, Carrion Feeder, and The Ozolith therefore do not form an infinite loop by themselves. " - "Carrion Feeder grows and The Ozolith stores future value, but Young Wolf stays in the graveyard after the second sacrifice." - ) - synergies = [ - "Carrion Feeder converts Young Wolf into sacrifice value, while The Ozolith preserves a corresponding counter for later combat.", - ] - risks = [ - "The Ozolith does not remove Young Wolf's counter before Undying checks its condition.", - "A separate effect must remove or neutralize the +1/+1 counter while Young Wolf is still on the battlefield to repeat the loop.", - ] - outcomes = [ - "Carrion Feeder receives a +1/+1 counter.", - "The Ozolith may receive a corresponding counter when its trigger resolves.", - "Young Wolf does not return through Undying if it left with a +1/+1 counter.", - ] - summary = [ - "Young Wolf leaves the battlefield while it still has the +1/+1 counter.", - "Undying uses that last battlefield state and does not trigger.", - "The Ozolith puts counters on itself later and cannot retroactively change Undying's check.", + "The Ozolith triggers separately and later puts a corresponding counter on itself.", ] + answer = f"{opening} Young Wolf, Carrion Feeder, and The Ozolith do not form an infinite loop by themselves. Carrion Feeder grows and The Ozolith stores future value, but Young Wolf stays in the graveyard after the second sacrifice." + synergies = ["Carrion Feeder converts Young Wolf into sacrifice value while The Ozolith stores corresponding counters for later combat."] + risks = ["The Ozolith does not remove Young Wolf's counter before Undying checks.", "A separate effect must remove or neutralize the counter while Young Wolf is on the battlefield."] + outcomes = ["Carrion Feeder receives a +1/+1 counter.", "The Ozolith may receive a corresponding counter.", "Young Wolf does not return if it left with a counter."] + summary = ["Young Wolf leaves with the counter still on it.", "Undying uses that last state and does not trigger.", "The Ozolith creates corresponding counters later."] - return StrategicSynthesis( - answer=answer, - combo_classification="non_combo", - synergies=synergies, - risks=risks, - combo_steps=steps, - outcomes=outcomes, - reasoning_summary=_deduplicate(summary), - ) + return StrategicSynthesis(answer, "non_combo", synergies, risks, steps, outcomes, _deduplicate(summary)) def _summary_from_verdicts(verdicts: list[ClaimVerdict]) -> list[str]: - return _deduplicate( - [verdict.explanation for verdict in verdicts if verdict.explanation] - ) + return _deduplicate([verdict.explanation for verdict in verdicts if verdict.explanation]) def _deduplicate(items: list[str]) -> list[str]: diff --git a/magicai/ui/static/app.js b/magicai/ui/static/app.js index 5e2e44a..1f9d7f2 100644 --- a/magicai/ui/static/app.js +++ b/magicai/ui/static/app.js @@ -853,6 +853,10 @@ function renderTechnicalDetails(result) { ["Herramientas del Juez", (result.judge_tool_calls || []).map(item => `${item.tool}: ${item.status}${item.cache_hit ? " (caché)" : ""}`).join(" · ") || "—"], ["Síntesis del Estratega", result.tactician_synthesized ? "sí" : "no"], ["Input analizado", result.input_analysis?.speech_act || "—"], + ["Idioma de respuesta", result.response_language || result.input_analysis?.language || "—"], + ["Registro del usuario", result.input_analysis?.register || "—"], + ["Respuesta completa", result.answer_complete ? "sí" : "no"], + ["Obligaciones de respuesta", (result.answer_obligations || []).map(item => item.code).join(" · ") || "—"], ["Afirmaciones evaluadas", String((result.claim_verdicts || []).length)], ["Consultas planificadas", String(result.queries_planned ?? 0)], ["Consultas completadas", String(result.queries_completed ?? 0)], diff --git a/magicai/validation/rule_renderer.py b/magicai/validation/rule_renderer.py index 8e73e49..937e26e 100644 --- a/magicai/validation/rule_renderer.py +++ b/magicai/validation/rule_renderer.py @@ -103,6 +103,19 @@ def render_rule_answer(knowledge: str) -> str | None: "de batalla bajo el control de su propietario con un contador +1/+1." ) + if _is_undying_dies_equivalence_question(q) and _has_rules( + knowledge, + ["undying", "dies"], + ): + return ( + "En Magic, una criatura muere exactamente cuando es puesta en un " + "cementerio desde el campo de batalla; no son dos eventos distintos. " + "Undying se dispara por ese mismo movimiento y comprueba si el " + "permanente tenía contadores +1/+1 justo antes de abandonar el campo. " + "Si una carta entra al cementerio desde otra zona, como la mano o la " + "biblioteca, no ha muerto y Undying no se dispara por ese evento." + ) + if _is_undying_existing_counter_question(q) and _has_rules( knowledge, ["undying"], @@ -1339,6 +1352,27 @@ def _is_may_trigger_choice(question: str) -> bool: +def _is_undying_dies_equivalence_question(question: str) -> bool: + if "undying" not in question: + return False + mentions_dies = any(marker in question for marker in ("muere", "morir", "dies")) + mentions_graveyard = any(marker in question for marker in ("cementerio", "graveyard")) + asks_relation = any( + marker in question + for marker in ( + "es cuando", + "no cuando", + "es lo mismo", + "mismo evento", + "equivale", + "same event", + "same as", + "rather than", + ) + ) + return mentions_dies and mentions_graveyard and asks_relation + + def _is_undying_existing_counter_question(question: str) -> bool: mentions_positive_counter = any( marker in question diff --git a/magicai/versioning.py b/magicai/versioning.py index 527b939..5bb290d 100644 --- a/magicai/versioning.py +++ b/magicai/versioning.py @@ -2,8 +2,8 @@ JUDGE_RESULT_SCHEMA_VERSION = "1.0" JUDGE_TOOL_RESULT_SCHEMA_VERSION = "1.0" -API_CONTRACT_VERSION = "1.5" -TACTICIAN_RESULT_SCHEMA_VERSION = "0.4" +API_CONTRACT_VERSION = "1.6" +TACTICIAN_RESULT_SCHEMA_VERSION = "0.5" # Packaging metadata must follow PEP 440. Public release names remain SemVer-like. PACKAGE_FALLBACK_VERSION = "0.1.1b0" diff --git a/scripts/ci_check.py b/scripts/ci_check.py index c815ef2..a79e193 100644 --- a/scripts/ci_check.py +++ b/scripts/ci_check.py @@ -17,6 +17,8 @@ "tests.repository.repository_health_test", "tests.repository.release_packaging_test", "tests.judge_tools.judge_tool_gateway_test", + "tests.language.language_policy_test", + "tests.conversation.casual_normalization_test", "tests.judge_tools.local_tools_test", "tests.api.judge_tool_api_test", "tests.tactician.tactician_tool_gateway_test", @@ -35,6 +37,9 @@ "tests.conversation.conversation_repository_test", "tests.conversation.tactician_strategy_context_test", "tests.validation.strategy_boundary_test", + "tests.validation.casual_judge_understanding_test", + "tests.quality.tactician_conversation_contract_test", + "tests.quality.tactician_conversation_regression_test", "tests.validation.oracle_derived_undying_test", "tests.validation.rule_renderer_test", "tests.ui.ui_assets_test", diff --git a/tests/api/tactician_api_contract_test.py b/tests/api/tactician_api_contract_test.py index ad8b516..5d0a52c 100644 --- a/tests/api/tactician_api_contract_test.py +++ b/tests/api/tactician_api_contract_test.py @@ -59,6 +59,11 @@ def test_tactician_response_remains_judge_evidence_compatible() -> None: queries_completed=2, judge_verified=True, investigation_plan={"queries_planned": 2}, + response_language="es", + language_policy={"response_language": "es", "language_locked": True}, + answer_obligations=[{"code": "direct_user_question", "required": True}], + answer_contract={"answer_complete": True}, + answer_complete=True, judge_result={"authority": "judge"}, ) payload = response.model_dump() @@ -71,6 +76,9 @@ def test_tactician_response_remains_judge_evidence_compatible() -> None: assert payload["input_analysis"]["speech_act"] == "question" assert payload["claim_verdicts"][0]["verdict"] == "supported" assert payload["judge_verified"] is True + assert payload["response_language"] == "es" + assert payload["answer_complete"] is True + assert payload["answer_obligations"][0]["code"] == "direct_user_question" def main() -> int: diff --git a/tests/conversation/casual_normalization_test.py b/tests/conversation/casual_normalization_test.py new file mode 100644 index 0000000..d49b6c2 --- /dev/null +++ b/tests/conversation/casual_normalization_test.py @@ -0,0 +1,39 @@ +from magicai.conversation.normalization import normalize_user_question + + +def test_graveyard_colloquialism_is_normalized_without_answering() -> None: + normalized = normalize_user_question( + "Entonces, Undying es cuando muere la criatura, no cuando pisa cementerio?", + session_language="es", + ) + assert normalized.language == "es" + assert normalized.register == "casual" + assert "es puesta en un cementerio" in normalized.canonical + assert "graveyard_colloquial" in normalized.transformations + assert "undying" in normalized.concepts + assert "graveyard" in normalized.concepts + + +def test_casual_battlefield_wording_becomes_retrieval_friendly() -> None: + normalized = normalize_user_question( + "¿Pisa mesa con los contadores?", + session_language="es", + ) + assert "entra al campo de batalla" in normalized.canonical + assert normalized.register == "casual" + + +def main() -> int: + tests = [ + test_graveyard_colloquialism_is_normalized_without_answering, + test_casual_battlefield_wording_becomes_retrieval_friendly, + ] + for test in tests: + test() + print(f"OK: {test.__name__}") + print(f"Casual normalization tests: {len(tests)}/{len(tests)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/language/__init__.py b/tests/language/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/language/language_policy_test.py b/tests/language/language_policy_test.py new file mode 100644 index 0000000..26a32bb --- /dev/null +++ b/tests/language/language_policy_test.py @@ -0,0 +1,43 @@ +from magicai.language.policy import resolve_language_policy + + +def test_spanish_turn_ignores_english_mtg_terms() -> None: + decision = resolve_language_policy( + "Entonces, Undying es cuando muere la criatura, no cuando pisa cementerio?", + session_language="es", + ) + assert decision.input_language == "es" + assert decision.response_language == "es" + assert decision.confidence == "high" + + +def test_ambiguous_card_only_followup_inherits_session_language() -> None: + decision = resolve_language_policy("¿Y The Ozolith?", session_language="es") + assert decision.response_language == "es" + assert decision.locked is True + + +def test_clear_english_turn_can_change_response_language() -> None: + decision = resolve_language_policy( + "Why does Undying not trigger when the creature dies with a counter?", + session_language="es", + ) + assert decision.input_language == "en" + assert decision.response_language == "en" + + +def main() -> int: + tests = [ + test_spanish_turn_ignores_english_mtg_terms, + test_ambiguous_card_only_followup_inherits_session_language, + test_clear_english_turn_can_change_response_language, + ] + for test in tests: + test() + print(f"OK: {test.__name__}") + print(f"Language policy tests: {len(tests)}/{len(tests)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/quality/cases/tactician_conversations/sprint12_2c.json b/tests/quality/cases/tactician_conversations/sprint12_2c.json new file mode 100644 index 0000000..27fab86 --- /dev/null +++ b/tests/quality/cases/tactician_conversations/sprint12_2c.json @@ -0,0 +1,35 @@ +{ + "schema_version": "1.0", + "scenarios": [ + { + "id": "TACT-CONV-OZOLITH-001", + "title": "Casual Undying clarification after a non-combo explanation", + "language": "es", + "turns": [ + { + "question": "¿Cómo funciona Young Wolf, Carrion Feeder y The Ozolith? ¿Hace combo?", + "expect": { + "strategy_intent": "combo_detection", + "combo_classification": "non_combo", + "response_language": "es", + "required_phrases": ["no forman un bucle infinito"], + "forbidden_phrases": ["The interaction creates value"] + } + }, + { + "question": "Entonces, Undying es cuando muere la criatura, no cuando pisa cementerio?", + "expect": { + "strategy_intent": "mechanic_equivalence", + "combo_classification": "non_combo", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "required_rules": ["700.4", "702.93a"], + "required_phrases": ["mismo evento", "otra zona", "Undying no se dispara"], + "forbidden_phrases": ["player loses the game", "jugador pierde el juego", "The interaction creates value"] + } + } + ] + } + ] +} diff --git a/tests/quality/tactician_conversation_contract_test.py b/tests/quality/tactician_conversation_contract_test.py new file mode 100644 index 0000000..da9a4d9 --- /dev/null +++ b/tests/quality/tactician_conversation_contract_test.py @@ -0,0 +1,26 @@ +from pathlib import Path + +from tests.quality.tactician_conversations import load_scenarios + + +CASES = Path(__file__).parent / "cases" / "tactician_conversations" / "sprint12_2c.json" + + +def test_conversation_case_contract_is_loadable() -> None: + scenarios = load_scenarios(CASES) + assert scenarios[0]["id"] == "TACT-CONV-OZOLITH-001" + assert len(scenarios[0]["turns"]) == 2 + for turn in scenarios[0]["turns"]: + assert turn["question"].strip() + assert isinstance(turn["expect"], dict) + + +def main() -> int: + test_conversation_case_contract_is_loadable() + print("OK: test_conversation_case_contract_is_loadable") + print("Tactician conversation contract tests: 1/1") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/quality/tactician_conversation_regression_test.py b/tests/quality/tactician_conversation_regression_test.py new file mode 100644 index 0000000..2f9e4e8 --- /dev/null +++ b/tests/quality/tactician_conversation_regression_test.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +from magicai.conversation.models import Conversation +from magicai.judge_tools.models import JudgeToolResult, JudgeToolStatus +from magicai.tactician.core import Tactician +from tests.quality.tactician_conversations import evaluate_turn, load_scenarios + + +CASES = Path(__file__).parent / "cases" / "tactician_conversations" / "sprint12_2c.json" + +CARDS = [ + { + "name": "Carrion Feeder", + "oracle_id": "carrion", + "oracle_text": "Sacrifice a creature: Put a +1/+1 counter on this creature.", + }, + { + "name": "The Ozolith", + "oracle_id": "ozolith", + "oracle_text": "Whenever a creature you control leaves the battlefield, if it had counters on it, put those counters on The Ozolith.", + }, + { + "name": "Young Wolf", + "oracle_id": "wolf", + "oracle_text": "Undying (When this creature dies, if it had no +1/+1 counters on it, return it with a +1/+1 counter.)", + }, +] + + +class FakeJudge: + def ask_result(self, conversation, question): + # The second answer deliberately reproduces the drift found in the + # manual JSON. The Tactician must not relay it. + wrong_answer = ( + "Cuando un jugador pierde el juego, las habilidades disparadas por ello se activan." + if "pisa cementerio" in question + else "Generic Judge strategy boundary." + ) + return SimpleNamespace(to_dict=lambda: { + "schema_version": "1.0", + "question": question, + "answer": wrong_answer, + "status": "answered" if "pisa cementerio" in question else "strategy_required", + "origin": "llm_validated" if "pisa cementerio" in question else "strategy_boundary", + "confidence": "medium", + "authority": "judge", + "cards": CARDS, + "rules": [], + "rulings": [], + "retrieval_queries": [], + "assumptions": [], + "warnings": [], + "source_versions": {}, + "source_health": {}, + "validation_attempts": 1, + "reviewed_by": ["tactician"], + "review_challenges": [], + "llm_called": True, + "timings": {}, + }) + + +class FakeGateway: + def execute(self, request, *, conversation=None, budget=None): + if request.tool == "oracle_lookup": + evidence = [ + {"kind": "card", "identifier": card["oracle_id"], "data": card} + for card in CARDS + ] + authority = "official_card_data" + elif request.tool == "rules_lookup": + evidence = [ + { + "kind": "rule", + "identifier": identifier, + "data": {"number": identifier, "title": f"Rule {identifier}", "rules": []}, + } + for identifier in request.arguments["identifiers"] + ] + authority = "official_rules" + elif request.tool == "rulings_lookup": + evidence = [{ + "kind": "ruling", + "identifier": "ozolith:2020-04-17", + "data": { + "oracle_id": "ozolith", + "card_name": "The Ozolith", + "published_at": "2020-04-17", + "source": "wotc", + "comment": "The Ozolith does not move counters off the creature that left the battlefield.", + }, + }] + authority = "official_rulings" + else: + raise AssertionError(request.tool) + return JudgeToolResult( + tool=request.tool, + status=JudgeToolStatus.SUCCESS, + authority=authority, + provider="fake", + purpose=request.purpose, + arguments=request.arguments, + evidence=evidence, + budget=budget.snapshot() if budget else {}, + ) + + +def test_ozolith_conversation_regression() -> None: + scenario = load_scenarios(CASES)[0] + conversation = Conversation(language=scenario["language"]) + tactician = Tactician(judge=FakeJudge(), tool_gateway=FakeGateway()) + + for turn in scenario["turns"]: + payload = tactician.ask_result(conversation, turn["question"]).to_dict() + failures = evaluate_turn(payload, turn["expect"]) + assert not failures, "; ".join(failures) + + assert conversation.language == "es" + assert conversation.strategy_context["answer_complete"] is True + assert conversation.strategy_context["judge_verified"] is True + + +def main() -> int: + test_ozolith_conversation_regression() + print("OK: test_ozolith_conversation_regression") + print("Tactician conversation regression tests: 1/1") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/quality/tactician_conversations/__init__.py b/tests/quality/tactician_conversations/__init__.py new file mode 100644 index 0000000..3bf881c --- /dev/null +++ b/tests/quality/tactician_conversations/__init__.py @@ -0,0 +1,4 @@ +from tests.quality.tactician_conversations.loader import load_scenarios +from tests.quality.tactician_conversations.evaluator import evaluate_turn + +__all__ = ["load_scenarios", "evaluate_turn"] diff --git a/tests/quality/tactician_conversations/evaluator.py b/tests/quality/tactician_conversations/evaluator.py new file mode 100644 index 0000000..fe7f3bd --- /dev/null +++ b/tests/quality/tactician_conversations/evaluator.py @@ -0,0 +1,28 @@ +from __future__ import annotations + + +def evaluate_turn(payload: dict, expected: dict) -> list[str]: + failures: list[str] = [] + for field in ( + "strategy_intent", + "combo_classification", + "response_language", + "judge_verified", + "answer_complete", + ): + if field in expected and payload.get(field) != expected[field]: + failures.append(f"{field}: expected {expected[field]!r}, got {payload.get(field)!r}") + + answer = str(payload.get("answer", "")) + for phrase in expected.get("required_phrases", []): + if phrase not in answer: + failures.append(f"missing required phrase: {phrase}") + for phrase in expected.get("forbidden_phrases", []): + if phrase.casefold() in answer.casefold(): + failures.append(f"forbidden phrase present: {phrase}") + + rule_numbers = {str(item.get("number", "")) for item in payload.get("rules", [])} + for number in expected.get("required_rules", []): + if number not in rule_numbers: + failures.append(f"missing required rule: {number}") + return failures diff --git a/tests/quality/tactician_conversations/loader.py b/tests/quality/tactician_conversations/loader.py new file mode 100644 index 0000000..14013ef --- /dev/null +++ b/tests/quality/tactician_conversations/loader.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +import json +from pathlib import Path + + +def load_scenarios(path: str | Path) -> list[dict]: + payload = json.loads(Path(path).read_text(encoding="utf-8")) + assert payload.get("schema_version") == "1.0" + scenarios = payload.get("scenarios") + assert isinstance(scenarios, list) and scenarios + return scenarios diff --git a/tests/tactician/tactician_core_test.py b/tests/tactician/tactician_core_test.py index c644d63..2d2e187 100644 --- a/tests/tactician/tactician_core_test.py +++ b/tests/tactician/tactician_core_test.py @@ -103,13 +103,17 @@ def test_tactician_consumes_judge_package_without_direct_sources() -> None: assert payload["authority_trace"] == [ "judge:factual_evidence", "judge:tool_gateway", + "tactician:language_policy", "tactician:input_analysis", "tactician:claim_evaluation", "tactician:strategic_synthesis", + "tactician:answer_contract", "judge:evidence_verification", ] assert payload["tactician_synthesized"] is True assert payload["judge_verified"] is True + assert payload["answer_complete"] is True + assert payload["response_language"] == "es" assert payload["judge_tool_calls"][0]["status"] == "success" assert "sinergia de sacrificio" in payload["answer"] assert "No es un bucle infinito" in payload["answer"] diff --git a/tests/ui/ui_usability_test.py b/tests/ui/ui_usability_test.py index 94a400d..bb9817f 100644 --- a/tests/ui/ui_usability_test.py +++ b/tests/ui/ui_usability_test.py @@ -136,6 +136,9 @@ def test_strategy_handoff_renders_tactician_result_and_combo_trace() -> None: assert '["Clasificación de combo", result.combo_classification || "—"]' in javascript assert '["Cartas heredadas", (result.inherited_cards || []).join(" · ") || "—"]' in javascript assert '["Input analizado", result.input_analysis?.speech_act || "—"]' in javascript + assert '["Idioma de respuesta", result.response_language || result.input_analysis?.language || "—"]' in javascript + assert '["Respuesta completa", result.answer_complete ? "sí" : "no"]' in javascript + assert '["Obligaciones de respuesta", (result.answer_obligations || []).map(item => item.code).join(" · ") || "—"]' in javascript assert '["Afirmaciones evaluadas", String((result.claim_verdicts || []).length)]' in javascript assert '["Verificado por evidencia", result.judge_verified ? "sí" : "no"]' in javascript assert 'container.className = "strategy-summary"' in javascript diff --git a/tests/validation/casual_judge_understanding_test.py b/tests/validation/casual_judge_understanding_test.py new file mode 100644 index 0000000..66605d0 --- /dev/null +++ b/tests/validation/casual_judge_understanding_test.py @@ -0,0 +1,35 @@ +from magicai.context import AssistantContext +from magicai.knowledge_builder import build_knowledge +from magicai.validation.rule_renderer import render_rule_answer + + +def test_judge_answers_casual_undying_equivalence_professionally() -> None: + context = AssistantContext( + question="Entonces, Undying es cuando muere la criatura, no cuando pisa cementerio?", + canonical_question="Entonces, Undying es cuando muere la criatura, no cuando es puesta en un cementerio?", + intent="judge", + language="es", + input_register="casual", + rules=[ + {"number": "700.4", "title": "The term dies means is put into a graveyard from the battlefield.", "rules": []}, + {"number": "702.93a", "title": "Undying is a triggered ability.", "rules": []}, + ], + ) + answer = render_rule_answer(build_knowledge(context)) + assert answer is not None + assert "no son dos eventos distintos" in answer + assert "desde otra zona" in answer + assert "Undying" in answer + assert "pisa cementerio" not in answer + assert "jugador pierde el juego" not in answer + + +def main() -> int: + test_judge_answers_casual_undying_equivalence_professionally() + print("OK: test_judge_answers_casual_undying_equivalence_professionally") + print("Casual Judge understanding tests: 1/1") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 4bd6423848aba5de50b7e31d803ef5f2387e7075 Mon Sep 17 00:00:00 2001 From: Manuel David Villalba Escamilla Date: Thu, 16 Jul 2026 23:00:51 +0200 Subject: [PATCH 6/7] Sprint 12.2d - Add response orchestration and conversational gauntlet --- docs/API_CONTRACT.md | 14 +- docs/COMMANDS.md | 36 +- docs/DEV_COMMANDS.md | 22 + docs/ROADMAP.md | 12 +- docs/STATUS.md | 48 +- docs/TACTICIAN.md | 50 +- docs/TACTICIAN_REASONING.md | 158 +++--- magicai/api/schemas.py | 6 + magicai/tactician/answer_contract.py | 30 +- magicai/tactician/core.py | 91 +++- magicai/tactician/factual_core.py | 194 +++++++ magicai/tactician/input_analysis.py | 4 +- magicai/tactician/intents.py | 17 +- magicai/tactician/models.py | 12 + magicai/tactician/orchestration.py | 177 +++++++ magicai/tactician/planner.py | 9 +- magicai/tactician/synthesis.py | 53 +- magicai/ui/static/app.js | 10 + magicai/versioning.py | 4 +- scripts/ci_check.py | 2 + scripts/promote_tactician_feedback.py | 132 +++++ tests/api/tactician_api_contract_test.py | 9 + .../ghave_combo_conversations.json | 488 ++++++++++++++++++ .../language_and_context.json | 455 ++++++++++++++++ .../ozolith_interactions.json | 387 ++++++++++++++ .../rules_clarifications.json | 390 ++++++++++++++ .../tactician_conversations/sprint12_2c.json | 35 -- .../tactician_conversation_contract_test.py | 22 +- .../tactician_conversation_regression_test.py | 135 +---- .../tactician_conversations/__init__.py | 4 +- .../tactician_conversations/evaluator.py | 43 +- .../tactician_conversations/fixtures.py | 186 +++++++ .../quality/tactician_conversations/loader.py | 21 +- .../quality/tactician_conversations/report.py | 50 ++ .../quality/tactician_conversations/runner.py | 100 ++++ .../semantic_assertions.py | 107 ++++ .../tactician_feedback_promotion_test.py | 57 ++ tests/tactician/tactician_core_test.py | 3 +- .../tactician_input_reasoning_test.py | 4 +- .../tactician_response_orchestration_test.py | 259 ++++++++++ 40 files changed, 3517 insertions(+), 319 deletions(-) create mode 100644 magicai/tactician/factual_core.py create mode 100644 magicai/tactician/orchestration.py create mode 100755 scripts/promote_tactician_feedback.py create mode 100644 tests/quality/cases/tactician_conversations/ghave_combo_conversations.json create mode 100644 tests/quality/cases/tactician_conversations/language_and_context.json create mode 100644 tests/quality/cases/tactician_conversations/ozolith_interactions.json create mode 100644 tests/quality/cases/tactician_conversations/rules_clarifications.json delete mode 100644 tests/quality/cases/tactician_conversations/sprint12_2c.json create mode 100644 tests/quality/tactician_conversations/fixtures.py create mode 100644 tests/quality/tactician_conversations/report.py create mode 100644 tests/quality/tactician_conversations/runner.py create mode 100644 tests/quality/tactician_conversations/semantic_assertions.py create mode 100644 tests/quality/tactician_feedback_promotion_test.py create mode 100644 tests/tactician/tactician_response_orchestration_test.py diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 35ac3b5..d4a74fc 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -1,11 +1,11 @@ # API contract -Current API contract version: `1.6`. +Current API contract version: `1.7`. ## Stable result families - Judge results use JudgeResult schema `1.0`. -- Tactician results use TacticianResult schema `0.5`. +- Tactician results use TacticianResult schema `0.6`. - Judge tool results use JudgeToolResult schema `1.0`. ## Main endpoints @@ -55,7 +55,7 @@ Unavailable capabilities return a structured result instead of being guessed. ## Tactician reasoning fields -TacticianResult `0.5` includes: +TacticianResult `0.6` includes: - `input_analysis` - `claim_verdicts` @@ -69,5 +69,13 @@ TacticianResult `0.5` includes: - `answer_obligations` - `answer_contract` - `answer_complete` +- `response_mode` +- `response_orchestration` +- `factual_core` +- `factual_core_coverage` +- `factual_core_preserved` +- `strategic_extension_required` + +`response_mode` is one of `judge_led`, `tactician_led`, or `hybrid`. A Judge-led response preserves the factual answer core; a Tactician-led response performs strategic synthesis; a hybrid response combines a Judge-owned rules explanation with a Tactician-owned strategic conclusion. These fields expose a concise, structured audit trail. They are not a hidden chain-of-thought transcript. `answer_complete` means the generated answer satisfied its deterministic semantic obligations; `judge_verified` additionally requires supporting Judge-owned evidence. diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 13c9f19..e4f1e78 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -140,23 +140,43 @@ curl -s -X POST http://127.0.0.1:8000/judge/tools/execute \ ## Tactician conversational understanding -Run the language and casual-input checks: +Run the focused orchestration checks: ```bash -python -m tests.language.language_policy_test -python -m tests.conversation.casual_normalization_test -python -m tests.validation.casual_judge_understanding_test +python -m tests.tactician.tactician_response_orchestration_test +python -m tests.tactician.tactician_input_reasoning_test +python -m tests.tactician.tactician_followup_reasoning_test +``` + +Run the deterministic 40-scenario conversation gauntlet: + +```bash +python -m tests.quality.tactician_conversations.runner \ + --mode fixture \ + --cases tests/quality/cases/tactician_conversations \ + --output-dir quality-results/tactician-conversations ``` -Run the seed multi-turn conversation regression: +Run the contract and CI regression wrappers: ```bash python -m tests.quality.tactician_conversation_contract_test python -m tests.quality.tactician_conversation_regression_test ``` -The seed scenario is stored in: +Run the same cases against the local MagicAI installation: -```text -tests/quality/cases/tactician_conversations/sprint12_2c.json +```bash +python -m tests.quality.tactician_conversations.runner \ + --mode local \ + --cases tests/quality/cases/tactician_conversations \ + --output-dir quality-results/tactician-conversations-local +``` + +Promote an exported result into a review-only candidate: + +```bash +python scripts/promote_tactician_feedback.py exported-result.json ``` + +Candidates are not added to the active regression corpus automatically. diff --git a/docs/DEV_COMMANDS.md b/docs/DEV_COMMANDS.md index aa275d2..9e308d1 100644 --- a/docs/DEV_COMMANDS.md +++ b/docs/DEV_COMMANDS.md @@ -58,3 +58,25 @@ Use bounded parallelism. Start with four workers and reduce the value if the mac ## Interpretation A harness PASS is not factual authority. Review premise validity, semantic contracts, evidence sufficiency, routing origin, and LLM-call counts. + +## Tactician conversation campaign + +Fixture mode is deterministic and safe for CI: + +```bash +PYTHONPATH=. python -m tests.quality.tactician_conversations.runner \ + --mode fixture \ + --cases tests/quality/cases/tactician_conversations \ + --output-dir quality-results/tactician-conversations +``` + +Local mode uses the installed sources and model and should be treated as a manual or scheduled campaign: + +```bash +PYTHONPATH=. python -m tests.quality.tactician_conversations.runner \ + --mode local \ + --cases tests/quality/cases/tactician_conversations \ + --output-dir quality-results/tactician-conversations-local +``` + +Review `summary.json` and `report.html`. A failed turn must identify the missing semantic concept, forbidden concept, evidence item, language policy, or response mode. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b1fa518..d6e3c2e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -84,15 +84,19 @@ The first major release should add mature deck analysis, authorized strategic so - Evidence-aware `judge_verified` and confidence derivation. - Seed data-driven multi-turn conversation regression. -### 12.2d — Tactician Conversation Gauntlet — next +### 12.2d — Response orchestration and Tactician Conversation Gauntlet — complete +- `judge_led`, `tactician_led`, and `hybrid` response arbitration. +- Deterministic factual-core extraction and preservation. +- Combo classification limited to combo-relevant turns. - Reusable multi-turn scenario execution. - Structured semantic and negative assertions. -- Controlled evidence fixtures and optional Ollama mode. -- Failure classification and JSON/HTML reports. +- Controlled evidence fixtures and optional local mode. +- JSON and HTML reports. - Manual promotion of reviewed UI feedback exports. +- 40 scenarios covering 58 turns. - Young Wolf, Carrion Feeder, and The Ozolith reasoning regression. -- Ghave and Ashnod's Altar follow-up sequencing and disruption regression. +- Ghave and Ashnod's Altar sequencing, requirements, and disruption regression. ### 12.3 — General autonomous investigation planner — next diff --git a/docs/STATUS.md b/docs/STATUS.md index f10ac8a..cca72cc 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -9,40 +9,42 @@ ## Current development line -Sprint `12.2c` adds conversational understanding, language consistency, and semantic answer validation. +Sprint `12.2d` adds response orchestration and the first full Tactician Conversation Gauntlet. Completed in this milestone: -- session-aware Spanish and English language policy; -- English MTG names and keywords excluded from strong language evidence; -- shared casual-language normalization for the Judge and Tactician; -- professional Judge responses after colloquial user input; -- rules-oriented Tactician intents; -- open questions separated from factual claims; -- evidence-aware mechanic-equivalence verdicts; -- deterministic answer obligations and forbidden-drift checks; -- corrected `judge_verified` and confidence derivation when evidence is complete; -- TacticianResult schema `0.5` and API contract `1.6`; -- first data-driven multi-turn Tactician conversation regression. +- explicit `judge_led`, `tactician_led`, and `hybrid` response modes; +- rules and mechanic questions led by the Judge factual core; +- strategic questions led by the Tactician; +- mixed rules-and-strategy questions handled through a hybrid path; +- deterministic factual-core extraction and coverage checks; +- protection against replacing a correct Judge answer with a generic combo template; +- combo classification suppressed when the current turn is not asking about a combo; +- `mechanic_resolution` intent for questions such as sacrificing Young Wolf; +- session-aware Spanish and English output preserved across all response fields; +- 40 data-driven conversational scenarios covering 58 turns; +- controlled Judge and Judge Tool Gateway fixtures for fast CI; +- semantic, negative, evidence, language, and continuity assertions; +- JSON and HTML conversation-gauntlet reports; +- a review-only command for promoting exported UI feedback into candidate cases; +- TacticianResult schema `0.6` and API contract `1.7`. ## Next development line -Sprint `12.2d` will build the wider Tactician Conversation Gauntlet: +Sprint `12.3` will focus on the general autonomous investigation planner: -- reusable multi-turn scenario runner; -- controlled Judge Tool Gateway fixtures; -- semantic and negative assertions; -- optional Ollama execution mode; -- JSON and HTML failure reports; -- manual promotion of exported feedback into reviewed regression candidates. - -Sprint `12.3` will then focus on general autonomous investigation planning. +- hypothesis decomposition; +- iterative evidence requests based on unresolved claims; +- evidence-sufficiency scoring; +- alternative and counterexample search; +- bounded research loops; +- reusable investigation traces beyond the currently supported interaction families. ## Known limitations - Casual-language normalization is conservative and currently covers a bounded vocabulary. -- Semantic answer contracts are implemented for the first rules-oriented and combo-requirement families. -- Multi-query planning is not yet general across arbitrary interactions. +- Factual-core templates are implemented for the first rules and combo families, not arbitrary Magic interactions. +- Fixture-mode semantic assertions are deterministic; local Ollama mode remains a manual or scheduled evaluation. - Combo reconstruction covers only narrow generic families. - Commander Spellbook is not connected. - EDHREC-style statistics remain permission-gated. diff --git a/docs/TACTICIAN.md b/docs/TACTICIAN.md index f4ae1ce..3d5f0a8 100644 --- a/docs/TACTICIAN.md +++ b/docs/TACTICIAN.md @@ -15,33 +15,43 @@ The Tactician analyzes: It does not open Oracle, rules, rulings, Commander Spellbook, EDHREC, or user collection files directly. It requests structured evidence through the Judge Tool Gateway. -## Current milestone: 0.5 +## Current milestone: 0.6 Implemented: - automatic handoff from `POST /ask`; - explicit `POST /tactician/ask`; -- strategic intent classification; +- strategic and rules-oriented intent classification; - previous-turn card inheritance; -- structured `strategy_intent`, `combo_classification`, `combo_steps`, and `outcomes`; -- generic recognition of a three-piece Undying loop; -- Judge review challenges for contradictory LLM answers; - executable, typed Judge Tool Gateway; -- bounded Oracle refresh through the Judge before strategic synthesis; -- tool-call provenance in `judge_tool_calls` and `judge_queries`; -- explicit `tactician_synthesized` metadata; -- speech-act, intent, and claim analysis for user input; -- bounded multi-tool investigation plans; -- claim verdicts with evidence identifiers; -- independent conversational synthesis even when the Judge already returned a factual answer; +- bounded Oracle, rules, and rulings requests; +- speech-act, intent, claim, and question-target analysis; - persisted strategic conversation context; -- `play_sequence`, `combo_disruption`, `combo_requirements`, and `interaction_hypothesis` intents; -- evidence-verification metadata and concise reasoning summaries; -- session-aware language policy that ignores English card names as language evidence; -- shared casual-language normalization for the Judge and Tactician; -- rules-oriented intents such as `mechanic_equivalence` and `combo_failure_explanation`; -- semantic answer obligations and `answer_complete` validation; -- the first data-driven multi-turn Tactician conversation regression. +- session-aware language policy and casual-input normalization; +- semantic answer obligations and evidence verification; +- explicit `judge_led`, `tactician_led`, and `hybrid` response modes; +- factual-core extraction, preservation, and coverage reporting; +- protection against generic strategic synthesis replacing a correct deterministic Judge answer; +- combo classification only when combo analysis is relevant to the current turn; +- 40 conversational regression scenarios covering 58 turns; +- fixture and local execution modes for the Tactician Conversation Gauntlet; +- JSON and HTML gauntlet reports; +- review-only promotion of exported feedback into candidate scenarios. + +## Response ownership + +```text +rules or mechanic resolution + → Judge-led response + +strategy, sequencing, disruption, or deck decisions + → Tactician-led response + +rules explanation plus strategic consequence + → hybrid response +``` + +A Judge-led response may be phrased naturally by the Tactician, but it must preserve every required factual proposition. If coverage is incomplete, the response is rejected or repaired before publication. ## Evidence loop @@ -58,7 +68,7 @@ Tactician forms a hypothesis → publish or declare uncertainty ``` -Milestone 0.5 adds language consistency, casual-input normalization, rules-oriented follow-ups, and answer-contract validation. The wider conversation gauntlet and general autonomous planner remain later milestones. +Milestone 0.6 adds response ownership and a reproducible conversation gauntlet. Sprint 12.3 generalizes the investigation loop beyond the current deterministic families. ## Personality diff --git a/docs/TACTICIAN_REASONING.md b/docs/TACTICIAN_REASONING.md index aec7bc7..751c94b 100644 --- a/docs/TACTICIAN_REASONING.md +++ b/docs/TACTICIAN_REASONING.md @@ -1,10 +1,10 @@ -# Tactician input reasoning +# Tactician input reasoning and response orchestration -Sprint 12.2c extends the structured reasoning layer introduced in Sprint 12.2b. The Tactician must understand the user's current question, preserve the conversation language, request the relevant rules evidence, and prove that its final answer satisfies the question it was asked. +Sprint 12.2d extends the structured reasoning layer with explicit response ownership, factual-core preservation, and a reproducible multi-turn conversation gauntlet. ## Goal -The Tactician must not treat the Judge's prose as the answer. It analyzes what the player is asserting, asking, correcting, or challenging, then requests the evidence needed to evaluate those claims. A completed response must also satisfy an explicit answer contract. +The Tactician must answer the current question rather than merely mentioning the active cards or repeating a generic combo classification. It must decide whether the current turn is primarily factual, strategic, or mixed, then preserve the appropriate authority boundary. ## Pipeline @@ -14,14 +14,60 @@ user wording → session language policy → speech-act and intent analysis → claim and question-target extraction - → bounded investigation plan - → Judge Tool Gateway calls + → bounded Judge Tool Gateway investigation → claim verdicts - → conversational strategic synthesis + → response-mode arbitration + → factual-core extraction + → Judge-led, Tactician-led, or hybrid synthesis + → factual-core coverage validation → answer-obligation validation → evidence verification metadata ``` +## Response modes + +### `judge_led` + +Used for rules and mechanic questions such as: + +```text +What happens if I sacrifice Young Wolf? +What does dying mean? +When is the counter condition checked? +``` + +The Judge owns the factual core. The Tactician may adapt tone and connect the answer to the conversation, but it may not replace the rules explanation with a generic strategic summary. + +### `tactician_led` + +Used for strategic questions such as: + +```text +Do these cards form a combo? +What order should I use? +Where can opponents interrupt the loop? +``` + +The Tactician synthesizes the line from Judge-owned evidence. + +### `hybrid` + +Used when a rules result must be connected to a strategic conclusion, for example explaining exactly why a proposed combo fails. + +## Factual core + +On a `judge_led` turn, the factual core is extracted from the Judge's validated answer sentence by sentence. This deliberately avoids adding production fixes for individual cards: the preservation rule applies equally to any direct rules answer. If Tactician wording drops a required Judge proposition, orchestration falls back to the validated Judge answer rather than replacing it with a generic strategic summary. + +Card-specific expectations belong in the regression corpus, where they test behavior without becoming application routing rules. + +The result exposes: + +- `factual_core`; +- `factual_core_coverage`; +- `factual_core_preserved`. + +A response cannot be marked complete when a required proposition is missing. + ## Language policy Card names and rules keywords are often English even inside a Spanish sentence. They are treated as neutral MTG terms instead of strong language evidence. @@ -34,7 +80,7 @@ clear current-turn language → configured default language ``` -The selected language applies to the answer and to user-visible strategic metadata such as risks, outcomes, claim explanations, and reasoning summaries. +The selected language applies to the answer and all user-visible strategic metadata. ## Casual-language normalization @@ -48,85 +94,55 @@ Examples: "se puede cortar" → "can be responded to or interrupted" ``` -The Judge still answers in a stable, professional register. The Tactician may remain warmer and more conversational. - -## Input analysis - -The analyzer records: - -- response language and language-policy decision; -- user register; -- canonical retrieval question; -- speech act such as question, hypothesis, challenge, or follow-up; -- strategic or rules-oriented intent; -- question target and answer focus; -- causal markers; -- detected concepts; -- structured claims. - -The result is auditable metadata, not a hidden chain-of-thought transcript. - -## Rules-oriented intents - -Sprint 12.2c adds: +The Judge answers in a stable professional register. The Tactician may remain warmer and more conversational. -- `rules_clarification` -- `mechanic_definition` -- `mechanic_equivalence` -- `combo_failure_explanation` -- `interaction_timing` +## Conversation Gauntlet -The Tactician owns the conversation, but factual rules claims remain Judge-owned and must be recovered through the Judge Tool Gateway. +The initial corpus is stored under: -## Claim verdicts - -Each extracted claim can be classified as: - -- `supported` -- `contradicted` -- `partially_supported` -- `insufficient_evidence` -- `strategic_opinion` - -A verdict records a concise explanation and the source identifiers that support it. Open questions are not automatically converted into factual claims. - -## Answer obligations +```text +tests/quality/cases/tactician_conversations/ +``` -Each response receives a small semantic contract. For an Undying terminology question, the required obligations include: +It contains 40 scenarios and 58 turns across: -- define `dies`; -- explain that dying and moving from the battlefield to a graveyard are the same event; -- exclude graveyard entry from other zones; -- apply the definition to Undying; -- apply it to the active interaction when relevant. +- direct mechanic resolutions; +- casual and mixed-language wording; +- incorrect interaction hypotheses; +- Young Wolf, Carrion Feeder, and The Ozolith; +- Young Wolf, Ashnod's Altar, and Ghave; +- sequencing, requirements, and disruption follow-ups; +- drift resistance when the Judge prose is not relevant. -The response is marked `answer_complete` only when all required checks pass and forbidden drift is absent. +Fixture mode is fast and deterministic: -## Current deterministic interaction family +```bash +python -m tests.quality.tactician_conversations.runner \ + --mode fixture \ + --cases tests/quality/cases/tactician_conversations +``` -The primary regression covers: +Local mode uses the installed MagicAI sources and model: -- Young Wolf; -- Carrion Feeder; -- The Ozolith; -- Undying; -- counters across zone changes; -- leaves-the-battlefield timing; -- last known information; -- casual follow-up wording about dying and entering a graveyard. +```bash +python -m tests.quality.tactician_conversations.runner \ + --mode local \ + --cases tests/quality/cases/tactician_conversations \ + --output-dir quality-results/tactician-conversations-local +``` -The Tactician explains that The Ozolith does not remove Young Wolf's +1/+1 counter before Undying checks the creature's last battlefield state. +Both modes generate `summary.json` and `report.html`. -## Conversation regression seed +## Feedback promotion -The first multi-turn scenario is stored under: +An exported UI result can be converted into a reviewable candidate: -```text -tests/quality/cases/tactician_conversations/sprint12_2c.json +```bash +python scripts/promote_tactician_feedback.py exported-result.json ``` -It verifies language, intent, required rules, semantic requirements, forbidden drift, answer completeness, and evidence verification. This is the seed for the wider Tactician Conversation Gauntlet planned for Sprint 12.2d. +The command never modifies the active regression corpus automatically. A human must add semantic and negative expectations before promotion. ## Limits -This milestone is not a universal natural-language theorem prover. Normalization, claim extraction, semantic checks, and verdict generation remain deterministic and intentionally bounded. Sprint 12.2d will broaden multi-turn scenario execution and reporting; Sprint 12.3 will generalize autonomous investigation planning. +This milestone is not a universal natural-language theorem prover. Factual-core extraction and semantic checks remain deterministic and intentionally bounded. Sprint 12.3 will generalize autonomous investigation planning and evidence sufficiency across broader interactions. diff --git a/magicai/api/schemas.py b/magicai/api/schemas.py index 0693e0a..f32ae72 100644 --- a/magicai/api/schemas.py +++ b/magicai/api/schemas.py @@ -82,6 +82,12 @@ class AskResponse(BaseModel): answer_obligations: list[dict[str, Any]] = Field(default_factory=list) answer_contract: dict[str, Any] = Field(default_factory=dict) answer_complete: bool = False + response_mode: str = "" + response_orchestration: dict[str, Any] = Field(default_factory=dict) + factual_core: list[dict[str, Any]] = Field(default_factory=list) + factual_core_coverage: dict[str, Any] = Field(default_factory=dict) + factual_core_preserved: bool = False + strategic_extension_required: bool = False judge_result: dict[str, Any] = Field(default_factory=dict) diff --git a/magicai/tactician/answer_contract.py b/magicai/tactician/answer_contract.py index 0798f0a..9e3415f 100644 --- a/magicai/tactician/answer_contract.py +++ b/magicai/tactician/answer_contract.py @@ -47,7 +47,20 @@ def build_answer_obligations(analysis: InputAnalysis, *, has_current_interaction intent = analysis.strategy_intent obligations: list[AnswerObligation] = [] - if intent is StrategyIntent.MECHANIC_EQUIVALENCE: + if intent is StrategyIntent.MECHANIC_RESOLUTION: + if "sacrifice" in analysis.concepts and "undying" in analysis.concepts: + obligations.extend([ + AnswerObligation("sacrifice_transition", "Explicar que sacrificar mueve la criatura del campo al cementerio." if spanish else "Explain that sacrificing moves the creature from the battlefield to the graveyard."), + AnswerObligation("dies_event", "Explicar que ese cambio de zona hace que la criatura muera." if spanish else "Explain that this zone change makes the creature die."), + AnswerObligation("undying_condition", "Explicar cuándo se dispara Undying." if spanish else "Explain when Undying triggers."), + AnswerObligation("undying_return", "Explicar cómo regresa la criatura por Undying." if spanish else "Explain how the creature returns through Undying."), + ]) + else: + obligations.append(AnswerObligation( + "direct_user_question", + "Resolver directamente el evento preguntado." if spanish else "Resolve the event asked about directly.", + )) + elif intent is StrategyIntent.MECHANIC_EQUIVALENCE: obligations.extend([ AnswerObligation("define_dies", "Definir qué significa morir." if spanish else "Define what dying means."), AnswerObligation("compare_events", "Aclarar si morir y pasar del campo al cementerio son el mismo evento." if spanish else "Clarify whether dying and moving from battlefield to graveyard are the same event."), @@ -117,6 +130,14 @@ def _check_obligation( analysis: InputAnalysis, has_current_interaction: bool, ) -> bool: + if code == "sacrifice_transition": + return _has_all(answer, ("campo de batalla", "cementerio")) or _has_all(answer, ("battlefield", "graveyard")) + if code == "dies_event": + return any(marker in answer for marker in ("muere", "morir", "dies")) + if code == "undying_condition": + return "undying" in answer and any(marker in answer for marker in ("sin contador", "no tenia", "no tenía", "no +1/+1", "had no +1/+1")) + if code == "undying_return": + return any(marker in answer for marker in ("vuelve", "regresa", "returns")) and "+1/+1" in answer if code == "define_dies": return _has_all(answer, ("muere", "cementerio", "campo de batalla")) or _has_all(answer, ("dies", "graveyard", "battlefield")) if code == "compare_events": @@ -144,12 +165,15 @@ def _check_obligation( if code == "identify_failed_transition": return any(marker in answer for marker in ("no se dispara", "no vuelve", "se rompe", "does not trigger", "does not return", "breaks")) if code == "explain_rule": - return any(marker in answer for marker in ("regla", "condicion", "condición", "ultimo estado", "último estado", "rule", "condition", "last battlefield state")) + return any(marker in answer for marker in ("regla", "condicion", "condición", "ultimo estado", "último estado", "justo antes", "inmediatamente antes", "rule", "condition", "last battlefield state", "immediately before")) if code == "list_required_permanents": return all(name in answer for name in ("young wolf", "ashnod's altar", "ghave")) if code == "list_initial_state": - return any(marker in answer for marker in ("sin contador", "without a +1/+1 counter")) + return any(marker in answer for marker in ("sin contador", "without a +1/+1 counter", "free of +1/+1 counters")) if code == "direct_user_question": + focus = set(analysis.answer_focus) + if "sacrifice" in focus or "sacrifice" in analysis.concepts: + return any(marker in answer for marker in ("sacrific", "graveyard", "cementerio", "muere", "dies")) return len(answer.split()) >= 5 return False diff --git a/magicai/tactician/core.py b/magicai/tactician/core.py index ad8a6ca..fb3f14d 100644 --- a/magicai/tactician/core.py +++ b/magicai/tactician/core.py @@ -19,9 +19,19 @@ ClaimVerdictStatus, evaluate_claims, ) +from magicai.tactician.factual_core import ( + extract_factual_core, + judge_answer_is_directly_usable, + preserve_factual_core, +) from magicai.tactician.input_analysis import analyze_user_input from magicai.tactician.intents import looks_like_referential_follow_up from magicai.tactician.models import TacticianResult +from magicai.tactician.orchestration import ( + ResponseMode, + choose_response_mode, + combo_classification_for_turn, +) from magicai.tactician.planner import plan_investigation from magicai.tactician.synthesis import synthesize_strategy @@ -91,6 +101,7 @@ def from_judge_result( "combo_requirements", "rules_clarification", "mechanic_definition", + "mechanic_resolution", "mechanic_equivalence", "combo_failure_explanation", "interaction_timing", @@ -137,11 +148,38 @@ def from_judge_result( tool_calls=tool_calls, ) + strategy_context = dict(getattr(conversation, "strategy_context", {}) or {}) + response_decision = choose_response_mode( + input_analysis, + judge_payload=judge_payload, + cards=merged_cards, + strategy_context=strategy_context, + ) + factual_core = extract_factual_core( + input_analysis, + judge_payload=judge_payload, + cards=merged_cards, + response_mode=response_decision.mode, + ) synthesis = synthesize_strategy( question=question, judge_payload=judge_payload, input_analysis=input_analysis, claim_verdicts=claim_verdicts, + response_decision=response_decision, + ) + combo_classification = combo_classification_for_turn( + analysis=input_analysis, + decision=response_decision, + synthesized_classification=synthesis.combo_classification, + strategy_context=strategy_context, + ) + final_answer, factual_coverage, factual_core_preserved = preserve_factual_core( + synthesis.answer, + judge_answer=str(judge_payload.get("answer", "")), + core=factual_core, + response_mode=response_decision.mode, + judge_answer_usable=judge_answer_is_directly_usable(judge_payload, input_analysis), ) has_current_interaction = len(merged_cards) >= 2 @@ -150,11 +188,12 @@ def from_judge_result( has_current_interaction=has_current_interaction, ) answer_contract = validate_answer_contract( - synthesis.answer, + final_answer, analysis=input_analysis, obligations=obligations, has_current_interaction=has_current_interaction, ) + answer_complete = answer_contract.complete and factual_coverage.complete judge_status = str(judge_payload.get("status", "")) if judge_status not in {"strategy_required", "answered"}: @@ -175,12 +214,13 @@ def from_judge_result( judge_verified = _judge_verified( claim_verdicts=claim_verdicts, - combo_classification=synthesis.combo_classification, + combo_classification=combo_classification, tool_calls=tool_calls, - answer_complete=answer_contract.complete, + answer_complete=answer_complete, + factual_core_preserved=factual_core_preserved, ) confidence = _strategy_confidence( - synthesis.combo_classification, + combo_classification, judge_payload, judge_verified=judge_verified, ) @@ -214,7 +254,8 @@ def from_judge_result( "tactician:language_policy", "tactician:input_analysis", "tactician:claim_evaluation", - "tactician:strategic_synthesis", + f"tactician:response_orchestration:{response_decision.mode.value}", + "tactician:factual_core_preservation", "tactician:answer_contract", "judge:evidence_verification" if judge_verified else "judge:evidence_incomplete", ] @@ -222,9 +263,13 @@ def from_judge_result( result = TacticianResult( question=question, - answer=synthesis.answer, + answer=final_answer, status=status, - origin="tactician_reasoned_strategy", + origin=( + "tactician_judge_led" if response_decision.mode is ResponseMode.JUDGE_LED + else "tactician_hybrid" if response_decision.mode is ResponseMode.HYBRID + else "tactician_reasoned_strategy" + ), confidence=confidence, strategy_intent=input_analysis.strategy_intent.value, cards=merged_cards, @@ -235,13 +280,16 @@ def from_judge_result( warnings=warnings, synergies=synthesis.synergies, risks=synthesis.risks, - combo_classification=synthesis.combo_classification, + combo_classification=combo_classification, combo_steps=synthesis.combo_steps, outcomes=synthesis.outcomes, inherited_cards=inherited_names, judge_queries=judge_queries, judge_tool_calls=tool_calls, - tactician_synthesized=True, + tactician_synthesized=( + response_decision.mode is not ResponseMode.JUDGE_LED + or final_answer.strip() != str(judge_payload.get("answer", "")).strip() + ), authority_trace=authority_trace, judge_result=judge_payload, input_analysis=input_analysis.to_dict(), @@ -254,8 +302,17 @@ def from_judge_result( response_language=input_analysis.language, language_policy=dict(input_analysis.language_policy), answer_obligations=[item.to_dict() for item in obligations], - answer_contract=answer_contract.to_dict(), - answer_complete=answer_contract.complete, + answer_contract={ + **answer_contract.to_dict(), + "factual_core_complete": factual_coverage.complete, + }, + answer_complete=answer_complete, + response_mode=response_decision.mode.value, + response_orchestration=response_decision.to_dict(), + factual_core=[item.to_dict() for item in factual_core], + factual_core_coverage=factual_coverage.to_dict(), + factual_core_preserved=factual_core_preserved, + strategic_extension_required=response_decision.strategic_extension_required, ) return result @@ -336,6 +393,8 @@ def _apply_result_state(conversation, result: TacticianResult) -> None: "response_language": result.response_language, "answer_complete": bool(result.answer_complete), "answer_obligations": deepcopy(result.answer_obligations), + "response_mode": result.response_mode, + "factual_core_preserved": bool(result.factual_core_preserved), } @@ -360,6 +419,7 @@ def _judge_verified( combo_classification: str, tool_calls: list[dict[str, Any]], answer_complete: bool, + factual_core_preserved: bool, ) -> bool: unresolved = [ verdict @@ -370,11 +430,16 @@ def _judge_verified( call.get("status") == "success" and call.get("evidence") for call in tool_calls ) - if unresolved or not answer_complete: + if unresolved or not answer_complete or not factual_core_preserved: return False if claim_verdicts: return successful_evidence - return combo_classification in {"infinite_combo", "non_combo", "repeatable_synergy"} and successful_evidence + return combo_classification in { + "infinite_combo", + "non_combo", + "repeatable_synergy", + "not_applicable", + } and successful_evidence def _merge_tool_evidence( diff --git a/magicai/tactician/factual_core.py b/magicai/tactician/factual_core.py new file mode 100644 index 0000000..223fe29 --- /dev/null +++ b/magicai/tactician/factual_core.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +import re +import unicodedata +from typing import Any + +from magicai.tactician.input_analysis import InputAnalysis +from magicai.tactician.intents import StrategyIntent +from magicai.tactician.orchestration import ResponseMode + + +@dataclass(frozen=True, slots=True) +class FactualCoreItem: + """A factual proposition that must survive final-response orchestration.""" + + code: str + statement: str + markers: tuple[tuple[str, ...], ...] + evidence: tuple[str, ...] = () + + def to_dict(self) -> dict[str, object]: + return { + "code": self.code, + "statement": self.statement, + "evidence": list(self.evidence), + } + + +@dataclass(slots=True) +class FactualCoreCoverage: + required: int = 0 + covered: int = 0 + covered_codes: list[str] = field(default_factory=list) + missing_codes: list[str] = field(default_factory=list) + + @property + def complete(self) -> bool: + return self.required == self.covered + + def to_dict(self) -> dict[str, object]: + return { + "required": self.required, + "covered": self.covered, + "covered_codes": list(self.covered_codes), + "missing": list(self.missing_codes), + "complete": self.complete, + } + + +def extract_factual_core( + analysis: InputAnalysis, + *, + judge_payload: dict[str, Any], + cards: list[dict[str, Any]], + response_mode: ResponseMode | None = None, +) -> list[FactualCoreItem]: + """Extract the Judge-owned factual core without card-specific production rules. + + Judge-led turns preserve the Judge's direct, validated answer sentence by + sentence. Strategic and hybrid turns remain governed by their answer + obligations and semantic gauntlet assertions instead of hard-coded card + combinations. + """ + + del cards # The preservation policy is deliberately card-agnostic. + if response_mode is not ResponseMode.JUDGE_LED: + return [] + if not _judge_answer_is_directly_usable(judge_payload, analysis): + return [] + + answer = str(judge_payload.get("answer", "")).strip() + rules = tuple(_rule_numbers(judge_payload)) + sentences = _sentences(answer) or [answer] + return [ + FactualCoreItem( + code=f"judge_fact_{index}", + statement=sentence, + markers=((_normalize(sentence),),), + evidence=rules, + ) + for index, sentence in enumerate(sentences, start=1) + if sentence.strip() + ] + + +def evaluate_factual_core(answer: str, core: list[FactualCoreItem]) -> FactualCoreCoverage: + normalized = _normalize(answer) + covered_codes: list[str] = [] + missing_codes: list[str] = [] + for item in core: + if _item_covered(normalized, item): + covered_codes.append(item.code) + else: + missing_codes.append(item.code) + return FactualCoreCoverage( + required=len(core), + covered=len(covered_codes), + covered_codes=covered_codes, + missing_codes=missing_codes, + ) + + +def preserve_factual_core( + answer: str, + *, + judge_answer: str, + core: list[FactualCoreItem], + response_mode: ResponseMode, + judge_answer_usable: bool, +) -> tuple[str, FactualCoreCoverage, bool]: + coverage = evaluate_factual_core(answer, core) + if coverage.complete: + return answer, coverage, True + + # A Judge-led response may never discard a validated Judge answer. Hybrid + # and Tactician-led responses are not repaired by blindly prepending prose; + # their own semantic contracts decide whether they are complete. + if ( + response_mode is ResponseMode.JUDGE_LED + and judge_answer_usable + and judge_answer.strip() + ): + repaired = judge_answer.strip() + repaired_coverage = evaluate_factual_core(repaired, core) + return repaired, repaired_coverage, repaired_coverage.complete + + return answer, coverage, not core + + +def judge_answer_is_directly_usable(judge_payload: dict[str, Any], analysis: InputAnalysis) -> bool: + return _judge_answer_is_directly_usable(judge_payload, analysis) + + +def _judge_answer_is_directly_usable(judge_payload: dict[str, Any], analysis: InputAnalysis) -> bool: + answer = _normalize(str(judge_payload.get("answer", ""))) + if not answer or str(judge_payload.get("status", "")) != "answered": + return False + + if analysis.language == "es" and any( + marker in answer + for marker in ("player loses the game", "the interaction creates value") + ): + return False + + if analysis.strategy_intent in { + StrategyIntent.MECHANIC_EQUIVALENCE, + StrategyIntent.MECHANIC_RESOLUTION, + StrategyIntent.RULES_CLARIFICATION, + StrategyIntent.MECHANIC_DEFINITION, + }: + drift = any( + marker in answer + for marker in ( + "jugador pierde el juego", + "abdica por empate", + "player loses the game", + "concedes the game", + "no hay evidencia suficiente para demostrar un bucle", + "insufficient evidence to prove an infinite loop", + ) + ) + if drift: + return False + return True + + +def _item_covered(answer: str, item: FactualCoreItem) -> bool: + for marker_group in item.markers: + if all(_normalize(marker) in answer for marker in marker_group): + return True + return False + + +def _sentences(text: str) -> list[str]: + return [ + part.strip() + for part in re.split(r"(?<=[.!?])\s+", text.strip()) + if part.strip() + ] + + +def _rule_numbers(payload: dict[str, Any]) -> list[str]: + return [ + str(item.get("number", "")) + for item in payload.get("rules", []) + if item.get("number") + ] + + +def _normalize(text: str) -> str: + value = unicodedata.normalize("NFKD", text or "") + value = "".join(char for char in value if not unicodedata.combining(char)) + return re.sub(r"\s+", " ", value.casefold()).strip() diff --git a/magicai/tactician/input_analysis.py b/magicai/tactician/input_analysis.py index af84cfb..bd4afd2 100644 --- a/magicai/tactician/input_analysis.py +++ b/magicai/tactician/input_analysis.py @@ -210,7 +210,7 @@ def _detect_concepts(normalized: str) -> list[str]: ("counters", ("contador", "counter")), ("leaves_battlefield", ("deja el campo", "abandona el campo", "leaves the battlefield")), ("graveyard", ("cementerio", "graveyard")), - ("battlefield", ("campo de batalla", "battlefield")), + ("battlefield", ("campo de batalla", "del campo al cementerio", "desde el campo", "battlefield")), ("last_known_information", ("antes de dejar", "justo antes", "last known", "cuando deja")), ("ozolith", ("ozolith",)), ("combo", ("combo", "bucle", "loop", "infinito", "infinite")), @@ -228,6 +228,8 @@ def _detect_concepts(normalized: str) -> list[str]: def _answer_focus(intent: StrategyIntent, concepts: list[str]) -> list[str]: if intent is StrategyIntent.MECHANIC_EQUIVALENCE: return ["define_dies", "compare_events", "exclude_other_zone_changes", "apply_current_interaction"] + if intent is StrategyIntent.MECHANIC_RESOLUTION: + return ["resolve_event", "explain_trigger_condition", "state_result"] if intent is StrategyIntent.COMBO_FAILURE_EXPLANATION: return ["identify_failed_transition", "explain_rule", "apply_current_interaction"] if intent is StrategyIntent.INTERACTION_TIMING: diff --git a/magicai/tactician/intents.py b/magicai/tactician/intents.py index b90419d..fa580b2 100644 --- a/magicai/tactician/intents.py +++ b/magicai/tactician/intents.py @@ -19,6 +19,7 @@ class StrategyIntent(str, Enum): INTERACTION_HYPOTHESIS = "interaction_hypothesis" RULES_CLARIFICATION = "rules_clarification" MECHANIC_DEFINITION = "mechanic_definition" + MECHANIC_RESOLUTION = "mechanic_resolution" MECHANIC_EQUIVALENCE = "mechanic_equivalence" COMBO_FAILURE_EXPLANATION = "combo_failure_explanation" INTERACTION_TIMING = "interaction_timing" @@ -42,7 +43,7 @@ class StrategyIntent(str, Enum): "en que orden", "en qué orden", "orden se juega", "orden del combo", "play sequence", "what order", ) _DISRUPTION_MARKERS = ( - "donde se corta", "dónde se corta", "como se corta", "cómo se corta", "cortar", "cortarlo", "interrump", "disrupt", "stop the combo", + "donde se corta", "dónde se corta", "como se corta", "cómo se corta", "cortar", "cortarlo", "interrump", "disrupt", "stop the combo", "stop it", "where can opponents stop", ) _REQUIREMENT_MARKERS = ( "que necesito", "qué necesito", "requisitos", "required pieces", "what do i need", @@ -62,9 +63,17 @@ class StrategyIntent(str, Enum): "when does", "at what point", "timing", ) _EQUIVALENCE_MARKERS = ( - "es cuando muere", "no cuando", "es lo mismo", "mismo evento", "equivale", "significa lo mismo", + "es cuando muere", "es morir", "no cuando", "no simplemente", "es lo mismo", "mismo evento", "equivale", "significa lo mismo", "same event", "is the same as", "rather than", ) +_RESOLUTION_MARKERS = ( + "que ocurre si", "qué ocurre si", "que pasa si", "qué pasa si", "que pasa cuando", "qué pasa cuando", + "what happens if", "what happens when", "what happens", +) +_RULE_ACTION_MARKERS = ( + "sacrific", "muere", "morir", "dies", "cementerio", "graveyard", "entra", "enter", + "sale del campo", "leaves the battlefield", "exilia", "exile", "se dispara", "trigger", +) _DEFINITION_MARKERS = ( "que es", "qué es", "define", "como funciona", "cómo funciona", "what is", "define", "how does", ) @@ -79,6 +88,10 @@ def classify_strategy_intent(question: str) -> StrategyIntent: if _looks_like_mechanic_equivalence(normalized): return StrategyIntent.MECHANIC_EQUIVALENCE + if any(marker in normalized for marker in _RESOLUTION_MARKERS) and any( + marker in normalized for marker in _RULE_ACTION_MARKERS + ): + return StrategyIntent.MECHANIC_RESOLUTION if any(marker in normalized for marker in _FAILURE_MARKERS): return StrategyIntent.COMBO_FAILURE_EXPLANATION if any(marker in normalized for marker in _TIMING_MARKERS): diff --git a/magicai/tactician/models.py b/magicai/tactician/models.py index 33db735..568be74 100644 --- a/magicai/tactician/models.py +++ b/magicai/tactician/models.py @@ -89,6 +89,12 @@ class TacticianResult: answer_obligations: list[dict[str, Any]] = field(default_factory=list) answer_contract: dict[str, Any] = field(default_factory=dict) answer_complete: bool = False + response_mode: str = "tactician_led" + response_orchestration: dict[str, Any] = field(default_factory=dict) + factual_core: list[dict[str, Any]] = field(default_factory=list) + factual_core_coverage: dict[str, Any] = field(default_factory=dict) + factual_core_preserved: bool = False + strategic_extension_required: bool = True def to_dict(self) -> dict[str, Any]: return { @@ -136,5 +142,11 @@ def to_dict(self) -> dict[str, Any]: "answer_obligations": list(self.answer_obligations), "answer_contract": dict(self.answer_contract), "answer_complete": bool(self.answer_complete), + "response_mode": self.response_mode, + "response_orchestration": dict(self.response_orchestration), + "factual_core": list(self.factual_core), + "factual_core_coverage": dict(self.factual_core_coverage), + "factual_core_preserved": bool(self.factual_core_preserved), + "strategic_extension_required": bool(self.strategic_extension_required), "judge_result": dict(self.judge_result), } diff --git a/magicai/tactician/orchestration.py b/magicai/tactician/orchestration.py new file mode 100644 index 0000000..abd9fdf --- /dev/null +++ b/magicai/tactician/orchestration.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Any + +from magicai.tactician.input_analysis import InputAnalysis +from magicai.tactician.intents import StrategyIntent + + +class ResponseMode(str, Enum): + """Profile that owns the wording of the final response.""" + + JUDGE_LED = "judge_led" + TACTICIAN_LED = "tactician_led" + HYBRID = "hybrid" + + +@dataclass(frozen=True, slots=True) +class ResponseDecision: + mode: ResponseMode + reason: str + factual_core_required: bool + strategic_extension_required: bool + combo_analysis_required: bool + + def to_dict(self) -> dict[str, object]: + return { + "mode": self.mode.value, + "reason": self.reason, + "factual_core_required": self.factual_core_required, + "strategic_extension_required": self.strategic_extension_required, + "combo_analysis_required": self.combo_analysis_required, + } + + +_JUDGE_LED_INTENTS = { + StrategyIntent.MECHANIC_RESOLUTION, + StrategyIntent.RULES_CLARIFICATION, + StrategyIntent.MECHANIC_DEFINITION, + StrategyIntent.MECHANIC_EQUIVALENCE, +} + +_TACTICIAN_LED_INTENTS = { + StrategyIntent.COMBO_DETECTION, + StrategyIntent.SYNERGY_ANALYSIS, + StrategyIntent.CARD_COMPARISON, + StrategyIntent.PLAY_RECOMMENDATION, + StrategyIntent.DECKBUILDING, + StrategyIntent.PLAY_SEQUENCE, + StrategyIntent.COMBO_DISRUPTION, + StrategyIntent.COMBO_REQUIREMENTS, +} + +_COMBO_INTENTS = { + StrategyIntent.COMBO_DETECTION, + StrategyIntent.COMBO_FAILURE_EXPLANATION, + StrategyIntent.PLAY_SEQUENCE, + StrategyIntent.COMBO_DISRUPTION, + StrategyIntent.COMBO_REQUIREMENTS, +} + + +def choose_response_mode( + analysis: InputAnalysis, + *, + judge_payload: dict[str, Any], + cards: list[dict[str, Any]], + strategy_context: dict[str, Any] | None = None, +) -> ResponseDecision: + """Choose whether the Judge or Tactician should lead the final answer. + + The decision is semantic rather than profile-based: a user may be talking to + the Tactician while asking a pure rules question. In that case, the Judge's + professional factual answer remains the core of the response. + """ + + intent = analysis.strategy_intent + context = strategy_context or {} + + if intent is StrategyIntent.INTERACTION_TIMING: + if len(cards) >= 2: + return ResponseDecision( + mode=ResponseMode.HYBRID, + reason="The timing question applies a rules window to an active multi-card interaction.", + factual_core_required=True, + strategic_extension_required=True, + combo_analysis_required=True, + ) + return ResponseDecision( + mode=ResponseMode.JUDGE_LED, + reason="The turn asks for the timing of a single rules event.", + factual_core_required=True, + strategic_extension_required=False, + combo_analysis_required=False, + ) + + if intent in _JUDGE_LED_INTENTS: + return ResponseDecision( + mode=ResponseMode.JUDGE_LED, + reason="The current turn asks for a rules or mechanic resolution.", + factual_core_required=True, + strategic_extension_required=False, + combo_analysis_required=False, + ) + + if intent in {StrategyIntent.COMBO_FAILURE_EXPLANATION, StrategyIntent.INTERACTION_HYPOTHESIS}: + return ResponseDecision( + mode=ResponseMode.HYBRID, + reason="The turn combines a factual interaction with a strategic conclusion.", + factual_core_required=True, + strategic_extension_required=True, + combo_analysis_required=True, + ) + + if intent in _TACTICIAN_LED_INTENTS: + return ResponseDecision( + mode=ResponseMode.TACTICIAN_LED, + reason="The current turn asks for strategic analysis or sequencing.", + factual_core_required=True, + strategic_extension_required=True, + combo_analysis_required=intent in _COMBO_INTENTS, + ) + + judge_is_direct = ( + str(judge_payload.get("status", "")) == "answered" + and str(judge_payload.get("confidence", "")) == "high" + and str(judge_payload.get("origin", "")) in { + "deterministic_rule", + "oracle_renderer", + "rulings_renderer", + "llm_validated", + } + ) + active_combo = str(context.get("combo_classification", "")) in { + "infinite_combo", + "non_combo", + "repeatable_synergy", + } + + if judge_is_direct and len(cards) <= 1 and not active_combo: + return ResponseDecision( + mode=ResponseMode.JUDGE_LED, + reason="The Judge already has a direct high-confidence factual answer.", + factual_core_required=True, + strategic_extension_required=False, + combo_analysis_required=False, + ) + + return ResponseDecision( + mode=ResponseMode.TACTICIAN_LED, + reason="The turn is strategic or depends on the active strategic context.", + factual_core_required=bool(cards), + strategic_extension_required=True, + combo_analysis_required=active_combo, + ) + + +def combo_classification_for_turn( + *, + analysis: InputAnalysis, + decision: ResponseDecision, + synthesized_classification: str, + strategy_context: dict[str, Any] | None = None, +) -> str: + """Return a combo label only when combo analysis is relevant to this turn.""" + + if decision.combo_analysis_required: + return synthesized_classification + + context = strategy_context or {} + if analysis.strategy_intent is StrategyIntent.MECHANIC_EQUIVALENCE: + inherited = str(context.get("combo_classification", "")) + if inherited in {"infinite_combo", "non_combo", "repeatable_synergy"}: + return inherited + + return "not_applicable" diff --git a/magicai/tactician/planner.py b/magicai/tactician/planner.py index d5f0445..770e937 100644 --- a/magicai/tactician/planner.py +++ b/magicai/tactician/planner.py @@ -56,13 +56,20 @@ def plan_investigation( "Relacionar el evento de morir con Undying y excluir entradas al cementerio desde otras zonas." if spanish else "Relate dying to Undying and exclude graveyard entry from other zones.", ]) else: - if "sacrifice" in concepts or "dies" in concepts: + if "sacrifice" in concepts: rules.extend(["701.21a", "700.4"]) goals.append( "Verificar la transición de sacrificar a morir." if spanish else "Verify the sacrifice-to-dies transition." ) + elif "dies" in concepts or {"battlefield", "graveyard"}.issubset(concepts): + rules.append("700.4") + goals.append( + "Verificar cuándo un cambio de zona cuenta como morir." + if spanish else + "Verify when a zone change counts as dying." + ) if "undying" in concepts or any("Undying" in str(card.get("oracle_text", "")) for card in cards): rules.extend(["702.93a", "603.4"]) goals.append( diff --git a/magicai/tactician/synthesis.py b/magicai/tactician/synthesis.py index e9e6468..91c6460 100644 --- a/magicai/tactician/synthesis.py +++ b/magicai/tactician/synthesis.py @@ -4,8 +4,10 @@ from typing import Any from magicai.tactician.claims import ClaimVerdict +from magicai.tactician.factual_core import judge_answer_is_directly_usable from magicai.tactician.input_analysis import InputAnalysis, SpeechAct from magicai.tactician.intents import StrategyIntent +from magicai.tactician.orchestration import ResponseDecision, ResponseMode from magicai.tactician.strategy import StrategyAnalysis, analyze_strategy @@ -26,9 +28,21 @@ def synthesize_strategy( judge_payload: dict[str, Any], input_analysis: InputAnalysis, claim_verdicts: list[ClaimVerdict], + response_decision: ResponseDecision | None = None, ) -> StrategicSynthesis: cards = list(judge_payload.get("cards", [])) names = {str(card.get("name", "")).casefold() for card in cards} + decision = response_decision + + if decision is not None and decision.mode is ResponseMode.JUDGE_LED: + judge_led = _synthesize_judge_led( + judge_payload=judge_payload, + input_analysis=input_analysis, + names=names, + claim_verdicts=claim_verdicts, + ) + if judge_led is not None: + return judge_led if input_analysis.strategy_intent is StrategyIntent.MECHANIC_EQUIVALENCE: return _synthesize_undying_dies_equivalence(input_analysis, names) @@ -70,6 +84,35 @@ def synthesize_strategy( ) +def _synthesize_judge_led( + *, + judge_payload: dict[str, Any], + input_analysis: InputAnalysis, + names: set[str], + claim_verdicts: list[ClaimVerdict], +) -> StrategicSynthesis | None: + """Keep a validated Judge answer intact on rules-led turns.""" + + if judge_answer_is_directly_usable(judge_payload, input_analysis): + answer = str(judge_payload.get("answer", "")).strip() + if answer: + return StrategicSynthesis( + answer=answer, + combo_classification="not_applicable", + synergies=[], + risks=[], + combo_steps=[], + outcomes=[], + reasoning_summary=_summary_from_verdicts(claim_verdicts) or [answer], + ) + + # A known semantic fallback is preferable to relaying a drifted Judge + # answer. It is mechanic-based and uses the recovered active context. + if input_analysis.strategy_intent is StrategyIntent.MECHANIC_EQUIVALENCE: + return _synthesize_undying_dies_equivalence(input_analysis, names) + + return None + def _synthesize_undying_dies_equivalence( input_analysis: InputAnalysis, names: set[str], @@ -296,7 +339,9 @@ def _synthesize_ozolith_interaction( "The Ozolith se dispara por separado y, cuando su habilidad se resuelve, pone sobre sí mismo un contador equivalente.", ] answer = ( - f"{opening} Por eso, Young Wolf, Carrion Feeder y The Ozolith no forman un bucle infinito por sí solos. " + f"{opening} Young Wolf deja el campo todavía con el contador +1/+1, así que Undying ve ese último estado y no se dispara. " + "The Ozolith se dispara por separado y pone después contadores equivalentes sobre sí mismo; no retiró previamente el contador del lobo. " + "Por eso, Young Wolf, Carrion Feeder y The Ozolith no forman un bucle infinito por sí solos. " "Carrion Feeder obtiene su contador y The Ozolith conserva valor para un combate posterior, pero Young Wolf permanece en el cementerio después del segundo sacrificio." ) synergies = ["Carrion Feeder convierte Young Wolf en valor de sacrificio y The Ozolith conserva contadores equivalentes para un combate posterior."] @@ -311,7 +356,11 @@ def _synthesize_ozolith_interaction( "The original counter ceases to exist when Young Wolf changes zones.", "The Ozolith triggers separately and later puts a corresponding counter on itself.", ] - answer = f"{opening} Young Wolf, Carrion Feeder, and The Ozolith do not form an infinite loop by themselves. Carrion Feeder grows and The Ozolith stores future value, but Young Wolf stays in the graveyard after the second sacrifice." + answer = ( + f"{opening} Young Wolf leaves the battlefield with the +1/+1 counter, so Undying sees that last state and does not trigger. " + "The Ozolith triggers separately and later puts corresponding counters on itself; it did not remove the Wolf's counter beforehand. " + "Young Wolf, Carrion Feeder, and The Ozolith do not form an infinite loop by themselves. Carrion Feeder grows and The Ozolith stores future value, but Young Wolf stays in the graveyard after the second sacrifice." + ) synergies = ["Carrion Feeder converts Young Wolf into sacrifice value while The Ozolith stores corresponding counters for later combat."] risks = ["The Ozolith does not remove Young Wolf's counter before Undying checks.", "A separate effect must remove or neutralize the counter while Young Wolf is on the battlefield."] outcomes = ["Carrion Feeder receives a +1/+1 counter.", "The Ozolith may receive a corresponding counter.", "Young Wolf does not return if it left with a counter."] diff --git a/magicai/ui/static/app.js b/magicai/ui/static/app.js index 1f9d7f2..a58be1c 100644 --- a/magicai/ui/static/app.js +++ b/magicai/ui/static/app.js @@ -33,6 +33,9 @@ const ORIGIN_LABELS = { premise_guard: "Control de premisa", strategy_boundary: "Frontera estratégica", tactician_strategy: "Análisis estratégico", + tactician_reasoned_strategy: "Síntesis estratégica razonada", + tactician_judge_led: "Respuesta liderada por el Juez", + tactician_hybrid: "Respuesta híbrida Juez–Estratega", tactician_judge_gate: "Respuesta factual del Juez", tactician_repair: "Reparación revisada", llm_validated: "LLM validado", @@ -623,6 +626,9 @@ function renderEvidence(result) { if (result.combo_classification) { row.appendChild(createPill(`Combo: ${result.combo_classification}`)); } + if (result.response_mode) { + row.appendChild(createPill(`Modo: ${result.response_mode}`)); + } summary.appendChild(row); if ((result.combo_steps || []).length || (result.outcomes || []).length) { @@ -846,6 +852,7 @@ function renderTechnicalDetails(result) { ["Confianza", result.confidence], ["Intent", result.intent || "—"], ["Intent estratégico", result.strategy_intent || "—"], + ["Modo de respuesta", result.response_mode || "—"], ["Clasificación de combo", result.combo_classification || "—"], ["Cartas heredadas", (result.inherited_cards || []).join(" · ") || "—"], ["Resultados", (result.outcomes || []).join(" · ") || "—"], @@ -856,6 +863,9 @@ function renderTechnicalDetails(result) { ["Idioma de respuesta", result.response_language || result.input_analysis?.language || "—"], ["Registro del usuario", result.input_analysis?.register || "—"], ["Respuesta completa", result.answer_complete ? "sí" : "no"], + ["Núcleo factual preservado", result.factual_core_preserved ? "sí" : "no"], + ["Cobertura factual", result.factual_core_coverage?.required == null ? "—" : `${result.factual_core_coverage.covered || 0}/${result.factual_core_coverage.required || 0}`], + ["Extensión estratégica requerida", result.strategic_extension_required ? "sí" : "no"], ["Obligaciones de respuesta", (result.answer_obligations || []).map(item => item.code).join(" · ") || "—"], ["Afirmaciones evaluadas", String((result.claim_verdicts || []).length)], ["Consultas planificadas", String(result.queries_planned ?? 0)], diff --git a/magicai/versioning.py b/magicai/versioning.py index 5bb290d..03aafb8 100644 --- a/magicai/versioning.py +++ b/magicai/versioning.py @@ -2,8 +2,8 @@ JUDGE_RESULT_SCHEMA_VERSION = "1.0" JUDGE_TOOL_RESULT_SCHEMA_VERSION = "1.0" -API_CONTRACT_VERSION = "1.6" -TACTICIAN_RESULT_SCHEMA_VERSION = "0.5" +API_CONTRACT_VERSION = "1.7" +TACTICIAN_RESULT_SCHEMA_VERSION = "0.6" # Packaging metadata must follow PEP 440. Public release names remain SemVer-like. PACKAGE_FALLBACK_VERSION = "0.1.1b0" diff --git a/scripts/ci_check.py b/scripts/ci_check.py index a79e193..67f7c0e 100644 --- a/scripts/ci_check.py +++ b/scripts/ci_check.py @@ -23,6 +23,7 @@ "tests.api.judge_tool_api_test", "tests.tactician.tactician_tool_gateway_test", "tests.tactician.tactician_input_reasoning_test", + "tests.tactician.tactician_response_orchestration_test", "tests.tactician.tactician_followup_reasoning_test", "tests.tactician.tactician_reviewer_test", "tests.tactician.tactician_strategy_test", @@ -40,6 +41,7 @@ "tests.validation.casual_judge_understanding_test", "tests.quality.tactician_conversation_contract_test", "tests.quality.tactician_conversation_regression_test", + "tests.quality.tactician_feedback_promotion_test", "tests.validation.oracle_derived_undying_test", "tests.validation.rule_renderer_test", "tests.ui.ui_assets_test", diff --git a/scripts/promote_tactician_feedback.py b/scripts/promote_tactician_feedback.py new file mode 100755 index 0000000..e7002bb --- /dev/null +++ b/scripts/promote_tactician_feedback.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Create a reviewable conversational-gauntlet candidate from an exported result. + +This command never modifies the regression corpus automatically. It creates a +candidate with structural expectations inferred from the export and marks it as +requiring human review before promotion. +""" + +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +import json +from pathlib import Path +import re +from typing import Any + + +def build_candidate(payload: dict[str, Any], *, candidate_id: str) -> dict[str, Any]: + question = str(payload.get("question", "")).strip() + if not question: + raise ValueError("the exported result does not contain a question") + + language = str(payload.get("response_language") or payload.get("input_analysis", {}).get("language") or "es") + if language not in {"es", "en"}: + language = "es" + + cards = [ + str(card.get("name", "")).strip() + for card in payload.get("cards", []) + if isinstance(card, dict) and card.get("name") + ] + rules = [ + str(rule.get("number", "")).strip() + for rule in payload.get("rules", []) + if isinstance(rule, dict) and rule.get("number") + ] + tools = [ + str(call.get("tool", "")).strip() + for call in payload.get("judge_tool_calls", []) + if isinstance(call, dict) and call.get("tool") + ] + + expect: dict[str, Any] = { + "response_language": language, + "review_required": True, + } + for field in ( + "strategy_intent", + "response_mode", + "combo_classification", + ): + value = payload.get(field) + if value not in (None, ""): + expect[field] = value + if cards: + expect["required_cards"] = _deduplicate(cards) + if rules: + expect["required_rules"] = _deduplicate(rules) + if tools: + expect["required_tools"] = _deduplicate(tools) + + return { + "schema_version": "1.0", + "candidate": { + "id": candidate_id, + "title": f"Candidate promoted from {question[:80]}", + "language": language, + "review_required": True, + "source": { + "origin": payload.get("origin", ""), + "schema_version": payload.get("schema_version", ""), + "created_at": datetime.now(timezone.utc).isoformat(), + }, + "turns": [ + { + "question": question, + "expect": expect, + "observed_answer": str(payload.get("answer", "")), + "review_notes": [ + "Replace observed_answer with semantic requirements before moving this candidate into the regression corpus.", + "Add forbidden concepts for the failure mode that motivated the promotion.", + ], + } + ], + }, + } + + +def promote_file(source: str | Path, output_dir: str | Path, *, candidate_id: str | None = None) -> Path: + source_path = Path(source) + payload = json.loads(source_path.read_text(encoding="utf-8")) + resolved_id = candidate_id or _candidate_id(source_path.stem) + candidate = build_candidate(payload, candidate_id=resolved_id) + + output = Path(output_dir) + output.mkdir(parents=True, exist_ok=True) + target = output / f"{resolved_id.casefold().replace('_', '-')}.candidate.json" + target.write_text(json.dumps(candidate, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return target + + +def _candidate_id(stem: str) -> str: + clean = re.sub(r"[^A-Za-z0-9]+", "-", stem).strip("-").upper() + return f"TACT-CANDIDATE-{clean or 'FEEDBACK'}" + + +def _deduplicate(items: list[str]) -> list[str]: + result: list[str] = [] + seen: set[str] = set() + for item in items: + key = item.casefold() + if item and key not in seen: + seen.add(key) + result.append(item) + return result + + +def main() -> int: + parser = argparse.ArgumentParser(description="Create a reviewable Tactician gauntlet candidate from an exported JSON result.") + parser.add_argument("source") + parser.add_argument("--output-dir", default="tests/quality/cases/tactician_conversations/candidates") + parser.add_argument("--id", dest="candidate_id") + args = parser.parse_args() + + target = promote_file(args.source, args.output_dir, candidate_id=args.candidate_id) + print(target) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/api/tactician_api_contract_test.py b/tests/api/tactician_api_contract_test.py index 5d0a52c..e882098 100644 --- a/tests/api/tactician_api_contract_test.py +++ b/tests/api/tactician_api_contract_test.py @@ -64,6 +64,12 @@ def test_tactician_response_remains_judge_evidence_compatible() -> None: answer_obligations=[{"code": "direct_user_question", "required": True}], answer_contract={"answer_complete": True}, answer_complete=True, + response_mode="tactician_led", + response_orchestration={"mode": "tactician_led"}, + factual_core=[{"code": "combo_loop", "statement": "The loop restores its state."}], + factual_core_coverage={"required": 1, "covered": 1, "complete": True}, + factual_core_preserved=True, + strategic_extension_required=True, judge_result={"authority": "judge"}, ) payload = response.model_dump() @@ -79,6 +85,9 @@ def test_tactician_response_remains_judge_evidence_compatible() -> None: assert payload["response_language"] == "es" assert payload["answer_complete"] is True assert payload["answer_obligations"][0]["code"] == "direct_user_question" + assert payload["response_mode"] == "tactician_led" + assert payload["factual_core_preserved"] is True + assert payload["factual_core_coverage"]["complete"] is True def main() -> int: diff --git a/tests/quality/cases/tactician_conversations/ghave_combo_conversations.json b/tests/quality/cases/tactician_conversations/ghave_combo_conversations.json new file mode 100644 index 0000000..f93e52c --- /dev/null +++ b/tests/quality/cases/tactician_conversations/ghave_combo_conversations.json @@ -0,0 +1,488 @@ +{ + "schema_version": "1.0", + "scenarios": [ + { + "id": "TACT-GHA-001", + "title": "¿Young Wolf, Ashnod's Altar y Ghave, Guru of Spores hacen combo?", + "language": "es", + "turns": [ + { + "question": "¿Young Wolf, Ashnod's Altar y Ghave, Guru of Spores hacen combo?", + "expect": { + "strategy_intent": "combo_detection", + "response_mode": "tactician_led", + "combo_classification": "infinite_combo", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "required_cards": [ + "Young Wolf", + "Ashnod's Altar", + "Ghave, Guru of Spores" + ], + "required_tools": [ + "oracle_lookup", + "rules_lookup" + ], + "required_phrases": [ + "combo infinito" + ], + "required_concepts": [ + "net_mana_and_token" + ] + } + } + ] + }, + { + "id": "TACT-GHA-002", + "title": "¿Hay un combo infinito entre Young Wolf, Ashnod's Altar y Ghave, Guru of Spores?", + "language": "es", + "turns": [ + { + "question": "¿Hay un combo infinito entre Young Wolf, Ashnod's Altar y Ghave, Guru of Spores?", + "expect": { + "strategy_intent": "combo_detection", + "response_mode": "tactician_led", + "combo_classification": "infinite_combo", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "required_cards": [ + "Young Wolf", + "Ashnod's Altar", + "Ghave, Guru of Spores" + ], + "required_tools": [ + "oracle_lookup", + "rules_lookup" + ], + "required_phrases": [ + "combo infinito" + ], + "required_concepts": [ + "net_mana_and_token" + ] + } + } + ] + }, + { + "id": "TACT-GHA-003", + "title": "¿Forman un bucle Young Wolf, Ashnod's Altar y Ghave, Guru of Spores?", + "language": "es", + "turns": [ + { + "question": "¿Forman un bucle Young Wolf, Ashnod's Altar y Ghave, Guru of Spores?", + "expect": { + "strategy_intent": "combo_detection", + "response_mode": "tactician_led", + "combo_classification": "infinite_combo", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "required_cards": [ + "Young Wolf", + "Ashnod's Altar", + "Ghave, Guru of Spores" + ], + "required_tools": [ + "oracle_lookup", + "rules_lookup" + ], + "required_phrases": [ + "combo infinito" + ], + "required_concepts": [ + "net_mana_and_token" + ] + } + } + ] + }, + { + "id": "TACT-GHA-004", + "title": "English Ghave combo", + "language": "en", + "turns": [ + { + "question": "Do Young Wolf, Ashnod's Altar, and Ghave, Guru of Spores form a combo?", + "expect": { + "strategy_intent": "combo_detection", + "response_mode": "tactician_led", + "combo_classification": "infinite_combo", + "response_language": "en", + "judge_verified": true, + "answer_complete": true, + "required_cards": [ + "Young Wolf", + "Ashnod's Altar", + "Ghave, Guru of Spores" + ], + "required_tools": [ + "oracle_lookup", + "rules_lookup" + ], + "required_phrases": [ + "infinite combo" + ], + "required_concepts": [ + "net_mana_and_token" + ] + } + } + ] + }, + { + "id": "TACT-GHA-005", + "title": "Sequence follow-up", + "language": "es", + "turns": [ + { + "question": "¿Young Wolf, Ashnod's Altar y Ghave, Guru of Spores hacen combo?", + "expect": { + "strategy_intent": "combo_detection", + "response_mode": "tactician_led", + "combo_classification": "infinite_combo", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "required_cards": [ + "Young Wolf", + "Ashnod's Altar", + "Ghave, Guru of Spores" + ], + "required_tools": [ + "oracle_lookup", + "rules_lookup" + ], + "required_phrases": [ + "combo infinito" + ], + "required_concepts": [ + "net_mana_and_token" + ] + } + }, + { + "question": "¿En qué orden se juega?", + "expect": { + "strategy_intent": "play_sequence", + "response_mode": "tactician_led", + "combo_classification": "infinite_combo", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "required_concepts": [ + "ghave_resets_counter", + "altar_produces_two", + "net_mana_and_token" + ] + } + } + ] + }, + { + "id": "TACT-GHA-006", + "title": "Disruption follow-up", + "language": "es", + "turns": [ + { + "question": "¿Young Wolf, Ashnod's Altar y Ghave, Guru of Spores hacen combo?", + "expect": { + "strategy_intent": "combo_detection", + "response_mode": "tactician_led", + "combo_classification": "infinite_combo", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "required_cards": [ + "Young Wolf", + "Ashnod's Altar", + "Ghave, Guru of Spores" + ], + "required_tools": [ + "oracle_lookup", + "rules_lookup" + ], + "required_phrases": [ + "combo infinito" + ], + "required_concepts": [ + "net_mana_and_token" + ] + } + }, + { + "question": "¿Dónde pueden cortarlo?", + "expect": { + "strategy_intent": "combo_disruption", + "response_mode": "tactician_led", + "combo_classification": "infinite_combo", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "required_concepts": [ + "graveyard_interruption", + "ghave_removal_window" + ] + } + } + ] + }, + { + "id": "TACT-GHA-007", + "title": "Requirements follow-up", + "language": "es", + "turns": [ + { + "question": "¿Young Wolf, Ashnod's Altar y Ghave, Guru of Spores hacen combo?", + "expect": { + "strategy_intent": "combo_detection", + "response_mode": "tactician_led", + "combo_classification": "infinite_combo", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "required_cards": [ + "Young Wolf", + "Ashnod's Altar", + "Ghave, Guru of Spores" + ], + "required_tools": [ + "oracle_lookup", + "rules_lookup" + ], + "required_phrases": [ + "combo infinito" + ], + "required_concepts": [ + "net_mana_and_token" + ] + } + }, + { + "question": "¿Qué necesito tener antes de empezar?", + "expect": { + "strategy_intent": "combo_requirements", + "response_mode": "tactician_led", + "combo_classification": "infinite_combo", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "required_concepts": [ + "requires_three_pieces", + "no_starting_mana" + ] + } + } + ] + }, + { + "id": "TACT-GHA-008", + "title": "Full Spanish combo conversation", + "language": "es", + "turns": [ + { + "question": "¿Young Wolf, Ashnod's Altar y Ghave, Guru of Spores hacen combo?", + "expect": { + "strategy_intent": "combo_detection", + "response_mode": "tactician_led", + "combo_classification": "infinite_combo", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "required_cards": [ + "Young Wolf", + "Ashnod's Altar", + "Ghave, Guru of Spores" + ], + "required_tools": [ + "oracle_lookup", + "rules_lookup" + ], + "required_phrases": [ + "combo infinito" + ], + "required_concepts": [ + "net_mana_and_token" + ] + } + }, + { + "question": "¿En qué orden se juega?", + "expect": { + "strategy_intent": "play_sequence", + "response_mode": "tactician_led", + "combo_classification": "infinite_combo", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "required_concepts": [ + "ghave_resets_counter", + "altar_produces_two", + "net_mana_and_token" + ] + } + }, + { + "question": "¿Dónde pueden cortarlo?", + "expect": { + "strategy_intent": "combo_disruption", + "response_mode": "tactician_led", + "combo_classification": "infinite_combo", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "required_concepts": [ + "graveyard_interruption", + "ghave_removal_window" + ] + } + }, + { + "question": "¿Qué necesito tener antes de empezar?", + "expect": { + "strategy_intent": "combo_requirements", + "response_mode": "tactician_led", + "combo_classification": "infinite_combo", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "required_concepts": [ + "requires_three_pieces", + "no_starting_mana" + ] + } + } + ] + }, + { + "id": "TACT-GHA-009", + "title": "Full English combo conversation", + "language": "en", + "turns": [ + { + "question": "Do Young Wolf, Ashnod's Altar, and Ghave, Guru of Spores form a combo?", + "expect": { + "strategy_intent": "combo_detection", + "response_mode": "tactician_led", + "combo_classification": "infinite_combo", + "response_language": "en", + "judge_verified": true, + "answer_complete": true, + "required_cards": [ + "Young Wolf", + "Ashnod's Altar", + "Ghave, Guru of Spores" + ], + "required_tools": [ + "oracle_lookup", + "rules_lookup" + ], + "required_phrases": [ + "infinite combo" + ], + "required_concepts": [ + "net_mana_and_token" + ] + } + }, + { + "question": "What order should I use?", + "expect": { + "strategy_intent": "play_sequence", + "response_mode": "tactician_led", + "combo_classification": "infinite_combo", + "response_language": "en", + "judge_verified": true, + "answer_complete": true, + "required_concepts": [ + "ghave_resets_counter", + "altar_produces_two", + "net_mana_and_token" + ] + } + }, + { + "question": "Where can opponents stop it?", + "expect": { + "strategy_intent": "combo_disruption", + "response_mode": "tactician_led", + "combo_classification": "infinite_combo", + "response_language": "en", + "judge_verified": true, + "answer_complete": true, + "required_concepts": [ + "graveyard_interruption", + "ghave_removal_window" + ] + } + }, + { + "question": "What do I need before I start?", + "expect": { + "strategy_intent": "combo_requirements", + "response_mode": "tactician_led", + "combo_classification": "infinite_combo", + "response_language": "en", + "judge_verified": true, + "answer_complete": true, + "required_concepts": [ + "requires_three_pieces", + "no_starting_mana" + ] + } + } + ] + }, + { + "id": "TACT-GHA-010", + "title": "Mixed terminology Spanish combo", + "language": "es", + "turns": [ + { + "question": "Entonces, Young Wolf + Ashnod's Altar + Ghave hacen infinite combo?", + "expect": { + "strategy_intent": "combo_detection", + "response_mode": "tactician_led", + "combo_classification": "infinite_combo", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "required_cards": [ + "Young Wolf", + "Ashnod's Altar", + "Ghave, Guru of Spores" + ], + "required_tools": [ + "oracle_lookup", + "rules_lookup" + ], + "required_phrases": [ + "combo infinito" + ], + "required_concepts": [ + "net_mana_and_token" + ] + } + }, + { + "question": "¿Qué necesito antes de empezar el loop?", + "expect": { + "strategy_intent": "combo_requirements", + "response_mode": "tactician_led", + "combo_classification": "infinite_combo", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "required_concepts": [ + "requires_three_pieces", + "no_starting_mana" + ] + } + } + ] + } + ] +} diff --git a/tests/quality/cases/tactician_conversations/language_and_context.json b/tests/quality/cases/tactician_conversations/language_and_context.json new file mode 100644 index 0000000..1105807 --- /dev/null +++ b/tests/quality/cases/tactician_conversations/language_and_context.json @@ -0,0 +1,455 @@ +{ + "schema_version": "1.0", + "scenarios": [ + { + "id": "TACT-LANG-001", + "title": "Entonces, what happens si sacrifico Young Wolf?", + "language": "es", + "turns": [ + { + "question": "Entonces, what happens si sacrifico Young Wolf?", + "expect": { + "strategy_intent": "mechanic_resolution", + "response_mode": "judge_led", + "combo_classification": "not_applicable", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "factual_core_preserved": true, + "minimum_factual_core_coverage": 1.0, + "required_rules": [ + "701.21a", + "700.4", + "702.93a" + ], + "required_tools": [ + "oracle_lookup", + "rules_lookup" + ], + "required_concepts": [ + "battlefield_to_graveyard", + "dies", + "undying_without_counter", + "returns_with_counter" + ], + "forbidden_phrases": [ + "bucle infinito", + "no hay evidencia suficiente" + ] + } + } + ] + }, + { + "id": "TACT-LANG-002", + "title": "¿Qué pasa si sacrifico Young Wolf y Undying trigger?", + "language": "es", + "turns": [ + { + "question": "¿Qué pasa si sacrifico Young Wolf y Undying trigger?", + "expect": { + "strategy_intent": "mechanic_resolution", + "response_mode": "judge_led", + "combo_classification": "not_applicable", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "factual_core_preserved": true, + "minimum_factual_core_coverage": 1.0, + "required_rules": [ + "701.21a", + "700.4", + "702.93a" + ], + "required_tools": [ + "oracle_lookup", + "rules_lookup" + ], + "required_concepts": [ + "battlefield_to_graveyard", + "dies", + "undying_without_counter", + "returns_with_counter" + ], + "forbidden_phrases": [ + "bucle infinito", + "no hay evidencia suficiente" + ] + } + } + ] + }, + { + "id": "TACT-LANG-003", + "title": "What happens if Young Wolf dies?", + "language": "en", + "turns": [ + { + "question": "What happens if Young Wolf dies?", + "expect": { + "strategy_intent": "mechanic_resolution", + "response_mode": "judge_led", + "combo_classification": "not_applicable", + "response_language": "en", + "judge_verified": true, + "answer_complete": true, + "factual_core_preserved": true, + "minimum_factual_core_coverage": 1.0, + "required_rules": [ + "700.4", + "702.93a" + ], + "required_tools": [ + "oracle_lookup", + "rules_lookup" + ], + "required_concepts": [ + "battlefield_to_graveyard", + "dies", + "undying_without_counter", + "returns_with_counter" + ], + "forbidden_phrases": [ + "bucle infinito", + "no hay evidencia suficiente" + ] + } + } + ] + }, + { + "id": "TACT-LANG-004", + "title": "¿Qué ocurre si Young Wolf pisa cementerio desde el campo de batalla?", + "language": "es", + "turns": [ + { + "question": "¿Qué ocurre si Young Wolf pisa cementerio desde el campo de batalla?", + "expect": { + "strategy_intent": "mechanic_resolution", + "response_mode": "judge_led", + "combo_classification": "not_applicable", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "factual_core_preserved": true, + "minimum_factual_core_coverage": 1.0, + "required_rules": [ + "700.4", + "702.93a" + ], + "required_tools": [ + "oracle_lookup", + "rules_lookup" + ], + "required_concepts": [ + "battlefield_to_graveyard", + "dies", + "undying_without_counter", + "returns_with_counter" + ], + "forbidden_phrases": [ + "bucle infinito", + "no hay evidencia suficiente" + ] + } + } + ] + }, + { + "id": "TACT-LANG-005", + "title": "Spanish Ozolith with English keywords", + "language": "es", + "turns": [ + { + "question": "¿Young Wolf, Carrion Feeder y The Ozolith hacen infinite loop?", + "expect": { + "strategy_intent": "combo_detection", + "response_mode": "tactician_led", + "combo_classification": "non_combo", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "factual_core_preserved": true, + "minimum_factual_core_coverage": 1.0, + "required_cards": [ + "Young Wolf", + "Carrion Feeder", + "The Ozolith" + ], + "required_tools": [ + "oracle_lookup", + "rules_lookup", + "rulings_lookup" + ], + "required_concepts": [ + "ozolith_does_not_reset", + "non_infinite_loop" + ], + "forbidden_phrases": [ + "jugador pierde el juego" + ] + } + } + ] + }, + { + "id": "TACT-LANG-006", + "title": "English Ozolith", + "language": "en", + "turns": [ + { + "question": "Do Young Wolf, Carrion Feeder, and The Ozolith form an infinite loop?", + "expect": { + "strategy_intent": "combo_detection", + "response_mode": "tactician_led", + "combo_classification": "non_combo", + "response_language": "en", + "judge_verified": true, + "answer_complete": true, + "factual_core_preserved": true, + "minimum_factual_core_coverage": 1.0, + "required_cards": [ + "Young Wolf", + "Carrion Feeder", + "The Ozolith" + ], + "required_tools": [ + "oracle_lookup", + "rules_lookup", + "rulings_lookup" + ], + "required_concepts": [ + "ozolith_does_not_reset", + "non_infinite_loop" + ], + "forbidden_phrases": [ + "jugador pierde el juego" + ] + } + } + ] + }, + { + "id": "TACT-LANG-007", + "title": "Spanish Ghave mixed terms", + "language": "es", + "turns": [ + { + "question": "¿Young Wolf, Ashnod's Altar y Ghave hacen infinite combo?", + "expect": { + "strategy_intent": "combo_detection", + "response_mode": "tactician_led", + "combo_classification": "infinite_combo", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "required_cards": [ + "Young Wolf", + "Ashnod's Altar", + "Ghave, Guru of Spores" + ], + "required_tools": [ + "oracle_lookup", + "rules_lookup" + ], + "required_phrases": [ + "combo infinito" + ], + "required_concepts": [ + "net_mana_and_token" + ] + } + }, + { + "question": "¿Where pueden cortarlo?", + "expect": { + "strategy_intent": "combo_disruption", + "response_mode": "tactician_led", + "combo_classification": "infinite_combo", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "required_concepts": [ + "graveyard_interruption", + "ghave_removal_window" + ] + } + } + ] + }, + { + "id": "TACT-LANG-008", + "title": "English Ghave context", + "language": "en", + "turns": [ + { + "question": "Do Young Wolf, Ashnod's Altar, and Ghave, Guru of Spores form a combo?", + "expect": { + "strategy_intent": "combo_detection", + "response_mode": "tactician_led", + "combo_classification": "infinite_combo", + "response_language": "en", + "judge_verified": true, + "answer_complete": true, + "required_cards": [ + "Young Wolf", + "Ashnod's Altar", + "Ghave, Guru of Spores" + ], + "required_tools": [ + "oracle_lookup", + "rules_lookup" + ], + "required_phrases": [ + "infinite combo" + ], + "required_concepts": [ + "net_mana_and_token" + ] + } + }, + { + "question": "What order should I use?", + "expect": { + "strategy_intent": "play_sequence", + "response_mode": "tactician_led", + "combo_classification": "infinite_combo", + "response_language": "en", + "judge_verified": true, + "answer_complete": true, + "required_concepts": [ + "ghave_resets_counter", + "altar_produces_two", + "net_mana_and_token" + ] + } + } + ] + }, + { + "id": "TACT-LANG-009", + "title": "Drift-resistant Spanish follow-up", + "language": "es", + "judge_behavior": "drift", + "turns": [ + { + "question": "¿Young Wolf, Carrion Feeder y The Ozolith hacen combo?", + "expect": { + "strategy_intent": "combo_detection", + "response_mode": "tactician_led", + "combo_classification": "non_combo", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "factual_core_preserved": true, + "minimum_factual_core_coverage": 1.0, + "required_cards": [ + "Young Wolf", + "Carrion Feeder", + "The Ozolith" + ], + "required_tools": [ + "oracle_lookup", + "rules_lookup", + "rulings_lookup" + ], + "required_concepts": [ + "ozolith_does_not_reset", + "non_infinite_loop" + ], + "forbidden_phrases": [ + "jugador pierde el juego" + ] + } + }, + { + "question": "Entonces Undying es morir, no simplemente llegar desde otra zona al cementerio, ¿no?", + "expect": { + "strategy_intent": "mechanic_equivalence", + "response_mode": "judge_led", + "combo_classification": "non_combo", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "factual_core_preserved": true, + "required_rules": [ + "700.4", + "702.93a" + ], + "required_concepts": [ + "same_event", + "other_zone_not_dies" + ], + "forbidden_phrases": [ + "jugador pierde el juego" + ] + } + } + ] + }, + { + "id": "TACT-LANG-010", + "title": "Spanish full Ghave continuity", + "language": "es", + "turns": [ + { + "question": "¿Young Wolf, Ashnod's Altar y Ghave, Guru of Spores hacen combo?", + "expect": { + "strategy_intent": "combo_detection", + "response_mode": "tactician_led", + "combo_classification": "infinite_combo", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "required_cards": [ + "Young Wolf", + "Ashnod's Altar", + "Ghave, Guru of Spores" + ], + "required_tools": [ + "oracle_lookup", + "rules_lookup" + ], + "required_phrases": [ + "combo infinito" + ], + "required_concepts": [ + "net_mana_and_token" + ] + } + }, + { + "question": "¿En qué orden?", + "expect": { + "strategy_intent": "play_sequence", + "response_mode": "tactician_led", + "combo_classification": "infinite_combo", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "required_concepts": [ + "ghave_resets_counter", + "altar_produces_two", + "net_mana_and_token" + ] + } + }, + { + "question": "¿Qué necesito?", + "expect": { + "strategy_intent": "combo_requirements", + "response_mode": "tactician_led", + "combo_classification": "infinite_combo", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "required_concepts": [ + "requires_three_pieces", + "no_starting_mana" + ] + } + } + ] + } + ] +} diff --git a/tests/quality/cases/tactician_conversations/ozolith_interactions.json b/tests/quality/cases/tactician_conversations/ozolith_interactions.json new file mode 100644 index 0000000..5e10ad1 --- /dev/null +++ b/tests/quality/cases/tactician_conversations/ozolith_interactions.json @@ -0,0 +1,387 @@ +{ + "schema_version": "1.0", + "scenarios": [ + { + "id": "TACT-OZO-001", + "title": "¿Young Wolf, Carrion Feeder y The Ozolith hacen combo?", + "language": "es", + "turns": [ + { + "question": "¿Young Wolf, Carrion Feeder y The Ozolith hacen combo?", + "expect": { + "strategy_intent": "combo_detection", + "response_mode": "tactician_led", + "combo_classification": "non_combo", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "factual_core_preserved": true, + "minimum_factual_core_coverage": 1.0, + "required_cards": [ + "Young Wolf", + "Carrion Feeder", + "The Ozolith" + ], + "required_tools": [ + "oracle_lookup", + "rules_lookup", + "rulings_lookup" + ], + "required_concepts": [ + "ozolith_does_not_reset", + "non_infinite_loop" + ], + "forbidden_phrases": [ + "jugador pierde el juego" + ] + } + } + ] + }, + { + "id": "TACT-OZO-002", + "title": "¿Cómo funciona Young Wolf con Carrion Feeder y The Ozolith? ¿Es infinito?", + "language": "es", + "turns": [ + { + "question": "¿Cómo funciona Young Wolf con Carrion Feeder y The Ozolith? ¿Es infinito?", + "expect": { + "strategy_intent": "combo_detection", + "response_mode": "tactician_led", + "combo_classification": "non_combo", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "factual_core_preserved": true, + "minimum_factual_core_coverage": 1.0, + "required_cards": [ + "Young Wolf", + "Carrion Feeder", + "The Ozolith" + ], + "required_tools": [ + "oracle_lookup", + "rules_lookup", + "rulings_lookup" + ], + "required_concepts": [ + "ozolith_does_not_reset", + "non_infinite_loop" + ], + "forbidden_phrases": [ + "jugador pierde el juego" + ] + } + } + ] + }, + { + "id": "TACT-OZO-003", + "title": "¿Hay un bucle con Young Wolf, Carrion Feeder y The Ozolith?", + "language": "es", + "turns": [ + { + "question": "¿Hay un bucle con Young Wolf, Carrion Feeder y The Ozolith?", + "expect": { + "strategy_intent": "combo_detection", + "response_mode": "tactician_led", + "combo_classification": "non_combo", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "factual_core_preserved": true, + "minimum_factual_core_coverage": 1.0, + "required_cards": [ + "Young Wolf", + "Carrion Feeder", + "The Ozolith" + ], + "required_tools": [ + "oracle_lookup", + "rules_lookup", + "rulings_lookup" + ], + "required_concepts": [ + "ozolith_does_not_reset", + "non_infinite_loop" + ], + "forbidden_phrases": [ + "jugador pierde el juego" + ] + } + } + ] + }, + { + "id": "TACT-OZO-004", + "title": "English Ozolith combo", + "language": "en", + "turns": [ + { + "question": "Do Young Wolf, Carrion Feeder, and The Ozolith form a combo?", + "expect": { + "strategy_intent": "combo_detection", + "response_mode": "tactician_led", + "combo_classification": "non_combo", + "response_language": "en", + "judge_verified": true, + "answer_complete": true, + "factual_core_preserved": true, + "minimum_factual_core_coverage": 1.0, + "required_cards": [ + "Young Wolf", + "Carrion Feeder", + "The Ozolith" + ], + "required_tools": [ + "oracle_lookup", + "rules_lookup", + "rulings_lookup" + ], + "required_concepts": [ + "ozolith_does_not_reset", + "non_infinite_loop" + ], + "forbidden_phrases": [ + "jugador pierde el juego" + ] + } + } + ] + }, + { + "id": "TACT-OZO-005", + "title": "Incorrect counter transfer premise", + "language": "es", + "turns": [ + { + "question": "Pero The Ozolith se lleva el contador antes, por lo que Undying se dispara otra vez.", + "expect": { + "strategy_intent": "interaction_hypothesis", + "response_mode": "hybrid", + "combo_classification": "non_combo", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "factual_core_preserved": true, + "required_concepts": [ + "undying_blocked_by_counter", + "ozolith_does_not_reset", + "non_infinite_loop" + ] + } + } + ] + }, + { + "id": "TACT-OZO-006", + "title": "Why the loop fails", + "language": "es", + "turns": [ + { + "question": "¿Por qué no funciona el combo de Young Wolf, Carrion Feeder y The Ozolith?", + "expect": { + "strategy_intent": "combo_failure_explanation", + "response_mode": "hybrid", + "combo_classification": "non_combo", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "factual_core_preserved": true, + "required_concepts": [ + "undying_blocked_by_counter" + ] + } + } + ] + }, + { + "id": "TACT-OZO-007", + "title": "Exact failure timing", + "language": "es", + "turns": [ + { + "question": "¿En qué momento falla Young Wolf con Carrion Feeder y The Ozolith?", + "expect": { + "strategy_intent": "interaction_timing", + "response_mode": "hybrid", + "combo_classification": "non_combo", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "factual_core_preserved": true, + "required_concepts": [ + "undying_blocked_by_counter" + ] + } + } + ] + }, + { + "id": "TACT-OZO-008", + "title": "Casual equivalence follow-up", + "language": "es", + "judge_behavior": "drift", + "turns": [ + { + "question": "¿Young Wolf, Carrion Feeder y The Ozolith hacen combo?", + "expect": { + "strategy_intent": "combo_detection", + "response_mode": "tactician_led", + "combo_classification": "non_combo", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "factual_core_preserved": true, + "minimum_factual_core_coverage": 1.0, + "required_cards": [ + "Young Wolf", + "Carrion Feeder", + "The Ozolith" + ], + "required_tools": [ + "oracle_lookup", + "rules_lookup", + "rulings_lookup" + ], + "required_concepts": [ + "ozolith_does_not_reset", + "non_infinite_loop" + ], + "forbidden_phrases": [ + "jugador pierde el juego" + ] + } + }, + { + "question": "Entonces, ¿Undying es cuando muere la criatura, no cuando pisa cementerio?", + "expect": { + "strategy_intent": "mechanic_equivalence", + "response_mode": "judge_led", + "combo_classification": "non_combo", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "factual_core_preserved": true, + "required_rules": [ + "700.4", + "702.93a" + ], + "required_concepts": [ + "same_event", + "other_zone_not_dies", + "undying_blocked_by_counter" + ], + "forbidden_phrases": [ + "jugador pierde el juego" + ] + } + } + ] + }, + { + "id": "TACT-OZO-009", + "title": "Failure follow-up after combo", + "language": "es", + "turns": [ + { + "question": "¿Young Wolf, Carrion Feeder y The Ozolith hacen combo?", + "expect": { + "strategy_intent": "combo_detection", + "response_mode": "tactician_led", + "combo_classification": "non_combo", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "factual_core_preserved": true, + "minimum_factual_core_coverage": 1.0, + "required_cards": [ + "Young Wolf", + "Carrion Feeder", + "The Ozolith" + ], + "required_tools": [ + "oracle_lookup", + "rules_lookup", + "rulings_lookup" + ], + "required_concepts": [ + "ozolith_does_not_reset", + "non_infinite_loop" + ], + "forbidden_phrases": [ + "jugador pierde el juego" + ] + } + }, + { + "question": "¿Y por qué no funciona el bucle?", + "expect": { + "strategy_intent": "combo_failure_explanation", + "response_mode": "hybrid", + "combo_classification": "non_combo", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "factual_core_preserved": true, + "required_concepts": [ + "undying_blocked_by_counter" + ] + } + } + ] + }, + { + "id": "TACT-OZO-010", + "title": "Timing follow-up after combo", + "language": "es", + "turns": [ + { + "question": "¿Young Wolf, Carrion Feeder y The Ozolith hacen combo?", + "expect": { + "strategy_intent": "combo_detection", + "response_mode": "tactician_led", + "combo_classification": "non_combo", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "factual_core_preserved": true, + "minimum_factual_core_coverage": 1.0, + "required_cards": [ + "Young Wolf", + "Carrion Feeder", + "The Ozolith" + ], + "required_tools": [ + "oracle_lookup", + "rules_lookup", + "rulings_lookup" + ], + "required_concepts": [ + "ozolith_does_not_reset", + "non_infinite_loop" + ], + "forbidden_phrases": [ + "jugador pierde el juego" + ] + } + }, + { + "question": "¿Y en qué momento exacto se rompe?", + "expect": { + "strategy_intent": "interaction_timing", + "response_mode": "hybrid", + "combo_classification": "non_combo", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "factual_core_preserved": true, + "required_concepts": [ + "undying_blocked_by_counter" + ] + } + } + ] + } + ] +} diff --git a/tests/quality/cases/tactician_conversations/rules_clarifications.json b/tests/quality/cases/tactician_conversations/rules_clarifications.json new file mode 100644 index 0000000..8844459 --- /dev/null +++ b/tests/quality/cases/tactician_conversations/rules_clarifications.json @@ -0,0 +1,390 @@ +{ + "schema_version": "1.0", + "scenarios": [ + { + "id": "TACT-RULE-001", + "title": "¿Qué ocurre si sacrifico Young Wolf?", + "language": "es", + "turns": [ + { + "question": "¿Qué ocurre si sacrifico Young Wolf?", + "expect": { + "strategy_intent": "mechanic_resolution", + "response_mode": "judge_led", + "combo_classification": "not_applicable", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "factual_core_preserved": true, + "minimum_factual_core_coverage": 1.0, + "required_rules": [ + "701.21a", + "700.4", + "702.93a" + ], + "required_tools": [ + "oracle_lookup", + "rules_lookup" + ], + "required_concepts": [ + "battlefield_to_graveyard", + "dies", + "undying_without_counter", + "returns_with_counter" + ], + "forbidden_phrases": [ + "bucle infinito", + "no hay evidencia suficiente" + ] + } + } + ] + }, + { + "id": "TACT-RULE-002", + "title": "¿Qué pasa si sacrifico Young Wolf?", + "language": "es", + "turns": [ + { + "question": "¿Qué pasa si sacrifico Young Wolf?", + "expect": { + "strategy_intent": "mechanic_resolution", + "response_mode": "judge_led", + "combo_classification": "not_applicable", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "factual_core_preserved": true, + "minimum_factual_core_coverage": 1.0, + "required_rules": [ + "701.21a", + "700.4", + "702.93a" + ], + "required_tools": [ + "oracle_lookup", + "rules_lookup" + ], + "required_concepts": [ + "battlefield_to_graveyard", + "dies", + "undying_without_counter", + "returns_with_counter" + ], + "forbidden_phrases": [ + "bucle infinito", + "no hay evidencia suficiente" + ] + } + } + ] + }, + { + "id": "TACT-RULE-003", + "title": "¿Qué pasa cuando sacrifico Young Wolf?", + "language": "es", + "turns": [ + { + "question": "¿Qué pasa cuando sacrifico Young Wolf?", + "expect": { + "strategy_intent": "mechanic_resolution", + "response_mode": "judge_led", + "combo_classification": "not_applicable", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "factual_core_preserved": true, + "minimum_factual_core_coverage": 1.0, + "required_rules": [ + "701.21a", + "700.4", + "702.93a" + ], + "required_tools": [ + "oracle_lookup", + "rules_lookup" + ], + "required_concepts": [ + "battlefield_to_graveyard", + "dies", + "undying_without_counter", + "returns_with_counter" + ], + "forbidden_phrases": [ + "bucle infinito", + "no hay evidencia suficiente" + ] + } + } + ] + }, + { + "id": "TACT-RULE-004", + "title": "¿Qué ocurre si Young Wolf muere?", + "language": "es", + "turns": [ + { + "question": "¿Qué ocurre si Young Wolf muere?", + "expect": { + "strategy_intent": "mechanic_resolution", + "response_mode": "judge_led", + "combo_classification": "not_applicable", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "factual_core_preserved": true, + "minimum_factual_core_coverage": 1.0, + "required_rules": [ + "700.4", + "702.93a" + ], + "required_tools": [ + "oracle_lookup", + "rules_lookup" + ], + "required_concepts": [ + "battlefield_to_graveyard", + "dies", + "undying_without_counter", + "returns_with_counter" + ], + "forbidden_phrases": [ + "bucle infinito", + "no hay evidencia suficiente" + ] + } + } + ] + }, + { + "id": "TACT-RULE-005", + "title": "¿Qué pasa si Young Wolf va al cementerio desde el campo de batalla?", + "language": "es", + "turns": [ + { + "question": "¿Qué pasa si Young Wolf va al cementerio desde el campo de batalla?", + "expect": { + "strategy_intent": "mechanic_resolution", + "response_mode": "judge_led", + "combo_classification": "not_applicable", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "factual_core_preserved": true, + "minimum_factual_core_coverage": 1.0, + "required_rules": [ + "700.4", + "702.93a" + ], + "required_tools": [ + "oracle_lookup", + "rules_lookup" + ], + "required_concepts": [ + "battlefield_to_graveyard", + "dies", + "undying_without_counter", + "returns_with_counter" + ], + "forbidden_phrases": [ + "bucle infinito", + "no hay evidencia suficiente" + ] + } + } + ] + }, + { + "id": "TACT-RULE-006", + "title": "Entonces, what happens si sacrifico Young Wolf?", + "language": "es", + "turns": [ + { + "question": "Entonces, what happens si sacrifico Young Wolf?", + "expect": { + "strategy_intent": "mechanic_resolution", + "response_mode": "judge_led", + "combo_classification": "not_applicable", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "factual_core_preserved": true, + "minimum_factual_core_coverage": 1.0, + "required_rules": [ + "701.21a", + "700.4", + "702.93a" + ], + "required_tools": [ + "oracle_lookup", + "rules_lookup" + ], + "required_concepts": [ + "battlefield_to_graveyard", + "dies", + "undying_without_counter", + "returns_with_counter" + ], + "forbidden_phrases": [ + "bucle infinito", + "no hay evidencia suficiente" + ] + } + } + ] + }, + { + "id": "TACT-RULE-007", + "title": "¿Qué ocurre si sacrifico a Young Wolf con un contador +1/+1?", + "language": "es", + "turns": [ + { + "question": "¿Qué ocurre si sacrifico a Young Wolf con un contador +1/+1?", + "expect": { + "strategy_intent": "mechanic_resolution", + "response_mode": "judge_led", + "combo_classification": "not_applicable", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "factual_core_preserved": true, + "minimum_factual_core_coverage": 1.0, + "required_rules": [ + "701.21a", + "700.4", + "702.93a" + ], + "required_tools": [ + "oracle_lookup", + "rules_lookup" + ], + "required_concepts": [ + "battlefield_to_graveyard", + "dies", + "undying_blocked_by_counter" + ], + "forbidden_phrases": [ + "bucle infinito", + "no hay evidencia suficiente" + ] + } + } + ] + }, + { + "id": "TACT-RULE-008", + "title": "¿Qué pasa cuando mando a Young Wolf del campo al cementerio?", + "language": "es", + "turns": [ + { + "question": "¿Qué pasa cuando mando a Young Wolf del campo al cementerio?", + "expect": { + "strategy_intent": "mechanic_resolution", + "response_mode": "judge_led", + "combo_classification": "not_applicable", + "response_language": "es", + "judge_verified": true, + "answer_complete": true, + "factual_core_preserved": true, + "minimum_factual_core_coverage": 1.0, + "required_rules": [ + "700.4", + "702.93a" + ], + "required_tools": [ + "oracle_lookup", + "rules_lookup" + ], + "required_concepts": [ + "battlefield_to_graveyard", + "dies", + "undying_without_counter", + "returns_with_counter" + ], + "forbidden_phrases": [ + "bucle infinito", + "no hay evidencia suficiente" + ] + } + } + ] + }, + { + "id": "TACT-RULE-009", + "title": "What happens if I sacrifice Young Wolf?", + "language": "en", + "turns": [ + { + "question": "What happens if I sacrifice Young Wolf?", + "expect": { + "strategy_intent": "mechanic_resolution", + "response_mode": "judge_led", + "combo_classification": "not_applicable", + "response_language": "en", + "judge_verified": true, + "answer_complete": true, + "factual_core_preserved": true, + "minimum_factual_core_coverage": 1.0, + "required_rules": [ + "701.21a", + "700.4", + "702.93a" + ], + "required_tools": [ + "oracle_lookup", + "rules_lookup" + ], + "required_concepts": [ + "battlefield_to_graveyard", + "dies", + "undying_without_counter", + "returns_with_counter" + ], + "forbidden_phrases": [ + "bucle infinito", + "no hay evidencia suficiente" + ] + } + } + ] + }, + { + "id": "TACT-RULE-010", + "title": "What happens when Young Wolf dies?", + "language": "en", + "turns": [ + { + "question": "What happens when Young Wolf dies?", + "expect": { + "strategy_intent": "mechanic_resolution", + "response_mode": "judge_led", + "combo_classification": "not_applicable", + "response_language": "en", + "judge_verified": true, + "answer_complete": true, + "factual_core_preserved": true, + "minimum_factual_core_coverage": 1.0, + "required_rules": [ + "700.4", + "702.93a" + ], + "required_tools": [ + "oracle_lookup", + "rules_lookup" + ], + "required_concepts": [ + "battlefield_to_graveyard", + "dies", + "undying_without_counter", + "returns_with_counter" + ], + "forbidden_phrases": [ + "bucle infinito", + "no hay evidencia suficiente" + ] + } + } + ] + } + ] +} diff --git a/tests/quality/cases/tactician_conversations/sprint12_2c.json b/tests/quality/cases/tactician_conversations/sprint12_2c.json deleted file mode 100644 index 27fab86..0000000 --- a/tests/quality/cases/tactician_conversations/sprint12_2c.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "schema_version": "1.0", - "scenarios": [ - { - "id": "TACT-CONV-OZOLITH-001", - "title": "Casual Undying clarification after a non-combo explanation", - "language": "es", - "turns": [ - { - "question": "¿Cómo funciona Young Wolf, Carrion Feeder y The Ozolith? ¿Hace combo?", - "expect": { - "strategy_intent": "combo_detection", - "combo_classification": "non_combo", - "response_language": "es", - "required_phrases": ["no forman un bucle infinito"], - "forbidden_phrases": ["The interaction creates value"] - } - }, - { - "question": "Entonces, Undying es cuando muere la criatura, no cuando pisa cementerio?", - "expect": { - "strategy_intent": "mechanic_equivalence", - "combo_classification": "non_combo", - "response_language": "es", - "judge_verified": true, - "answer_complete": true, - "required_rules": ["700.4", "702.93a"], - "required_phrases": ["mismo evento", "otra zona", "Undying no se dispara"], - "forbidden_phrases": ["player loses the game", "jugador pierde el juego", "The interaction creates value"] - } - } - ] - } - ] -} diff --git a/tests/quality/tactician_conversation_contract_test.py b/tests/quality/tactician_conversation_contract_test.py index da9a4d9..bf115bb 100644 --- a/tests/quality/tactician_conversation_contract_test.py +++ b/tests/quality/tactician_conversation_contract_test.py @@ -1,24 +1,32 @@ from pathlib import Path from tests.quality.tactician_conversations import load_scenarios +from tests.quality.tactician_conversations.semantic_assertions import known_concepts -CASES = Path(__file__).parent / "cases" / "tactician_conversations" / "sprint12_2c.json" +CASES = Path(__file__).parent / "cases" / "tactician_conversations" def test_conversation_case_contract_is_loadable() -> None: scenarios = load_scenarios(CASES) - assert scenarios[0]["id"] == "TACT-CONV-OZOLITH-001" - assert len(scenarios[0]["turns"]) == 2 - for turn in scenarios[0]["turns"]: - assert turn["question"].strip() - assert isinstance(turn["expect"], dict) + assert len(scenarios) == 40 + assert len({scenario["id"] for scenario in scenarios}) == 40 + assert sum(len(scenario["turns"]) for scenario in scenarios) == 58 + allowed_concepts = known_concepts() + for scenario in scenarios: + assert scenario["language"] in {"es", "en"} + for turn in scenario["turns"]: + assert turn["question"].strip() + expected = turn["expect"] + assert isinstance(expected, dict) + assert set(expected.get("required_concepts", [])) <= allowed_concepts + assert set(expected.get("forbidden_concepts", [])) <= allowed_concepts def main() -> int: test_conversation_case_contract_is_loadable() print("OK: test_conversation_case_contract_is_loadable") - print("Tactician conversation contract tests: 1/1") + print("Tactician conversation contract tests: 40 scenarios / 58 turns") return 0 diff --git a/tests/quality/tactician_conversation_regression_test.py b/tests/quality/tactician_conversation_regression_test.py index 2f9e4e8..03e3a85 100644 --- a/tests/quality/tactician_conversation_regression_test.py +++ b/tests/quality/tactician_conversation_regression_test.py @@ -1,132 +1,31 @@ from __future__ import annotations from pathlib import Path -from types import SimpleNamespace +from tempfile import TemporaryDirectory -from magicai.conversation.models import Conversation -from magicai.judge_tools.models import JudgeToolResult, JudgeToolStatus -from magicai.tactician.core import Tactician -from tests.quality.tactician_conversations import evaluate_turn, load_scenarios +from tests.quality.tactician_conversations.runner import run_gauntlet -CASES = Path(__file__).parent / "cases" / "tactician_conversations" / "sprint12_2c.json" +CASES = Path(__file__).parent / "cases" / "tactician_conversations" -CARDS = [ - { - "name": "Carrion Feeder", - "oracle_id": "carrion", - "oracle_text": "Sacrifice a creature: Put a +1/+1 counter on this creature.", - }, - { - "name": "The Ozolith", - "oracle_id": "ozolith", - "oracle_text": "Whenever a creature you control leaves the battlefield, if it had counters on it, put those counters on The Ozolith.", - }, - { - "name": "Young Wolf", - "oracle_id": "wolf", - "oracle_text": "Undying (When this creature dies, if it had no +1/+1 counters on it, return it with a +1/+1 counter.)", - }, -] - -class FakeJudge: - def ask_result(self, conversation, question): - # The second answer deliberately reproduces the drift found in the - # manual JSON. The Tactician must not relay it. - wrong_answer = ( - "Cuando un jugador pierde el juego, las habilidades disparadas por ello se activan." - if "pisa cementerio" in question - else "Generic Judge strategy boundary." - ) - return SimpleNamespace(to_dict=lambda: { - "schema_version": "1.0", - "question": question, - "answer": wrong_answer, - "status": "answered" if "pisa cementerio" in question else "strategy_required", - "origin": "llm_validated" if "pisa cementerio" in question else "strategy_boundary", - "confidence": "medium", - "authority": "judge", - "cards": CARDS, - "rules": [], - "rulings": [], - "retrieval_queries": [], - "assumptions": [], - "warnings": [], - "source_versions": {}, - "source_health": {}, - "validation_attempts": 1, - "reviewed_by": ["tactician"], - "review_challenges": [], - "llm_called": True, - "timings": {}, - }) - - -class FakeGateway: - def execute(self, request, *, conversation=None, budget=None): - if request.tool == "oracle_lookup": - evidence = [ - {"kind": "card", "identifier": card["oracle_id"], "data": card} - for card in CARDS - ] - authority = "official_card_data" - elif request.tool == "rules_lookup": - evidence = [ - { - "kind": "rule", - "identifier": identifier, - "data": {"number": identifier, "title": f"Rule {identifier}", "rules": []}, - } - for identifier in request.arguments["identifiers"] - ] - authority = "official_rules" - elif request.tool == "rulings_lookup": - evidence = [{ - "kind": "ruling", - "identifier": "ozolith:2020-04-17", - "data": { - "oracle_id": "ozolith", - "card_name": "The Ozolith", - "published_at": "2020-04-17", - "source": "wotc", - "comment": "The Ozolith does not move counters off the creature that left the battlefield.", - }, - }] - authority = "official_rulings" - else: - raise AssertionError(request.tool) - return JudgeToolResult( - tool=request.tool, - status=JudgeToolStatus.SUCCESS, - authority=authority, - provider="fake", - purpose=request.purpose, - arguments=request.arguments, - evidence=evidence, - budget=budget.snapshot() if budget else {}, - ) - - -def test_ozolith_conversation_regression() -> None: - scenario = load_scenarios(CASES)[0] - conversation = Conversation(language=scenario["language"]) - tactician = Tactician(judge=FakeJudge(), tool_gateway=FakeGateway()) - - for turn in scenario["turns"]: - payload = tactician.ask_result(conversation, turn["question"]).to_dict() - failures = evaluate_turn(payload, turn["expect"]) - assert not failures, "; ".join(failures) - - assert conversation.language == "es" - assert conversation.strategy_context["answer_complete"] is True - assert conversation.strategy_context["judge_verified"] is True +def test_fixture_conversation_gauntlet_passes() -> None: + with TemporaryDirectory() as directory: + result = run_gauntlet(CASES, mode="fixture", output_dir=directory) + assert result["scenario_count"] == 40 + assert result["turn_count"] == 58 + assert result["passed_scenarios"] == 40 + assert result["failed_scenarios"] == 0 + assert result["passed_turns"] == 58 + assert result["failed_turns"] == 0 + assert (Path(directory) / "summary.json").exists() + assert (Path(directory) / "report.html").exists() def main() -> int: - test_ozolith_conversation_regression() - print("OK: test_ozolith_conversation_regression") - print("Tactician conversation regression tests: 1/1") + test_fixture_conversation_gauntlet_passes() + print("OK: test_fixture_conversation_gauntlet_passes") + print("Tactician conversation regression tests: 40/40 scenarios, 58/58 turns") return 0 diff --git a/tests/quality/tactician_conversations/__init__.py b/tests/quality/tactician_conversations/__init__.py index 3bf881c..960610d 100644 --- a/tests/quality/tactician_conversations/__init__.py +++ b/tests/quality/tactician_conversations/__init__.py @@ -1,4 +1,4 @@ -from tests.quality.tactician_conversations.loader import load_scenarios from tests.quality.tactician_conversations.evaluator import evaluate_turn +from tests.quality.tactician_conversations.loader import load_scenarios -__all__ = ["load_scenarios", "evaluate_turn"] +__all__ = ["evaluate_turn", "load_scenarios"] diff --git a/tests/quality/tactician_conversations/evaluator.py b/tests/quality/tactician_conversations/evaluator.py index fe7f3bd..b95f909 100644 --- a/tests/quality/tactician_conversations/evaluator.py +++ b/tests/quality/tactician_conversations/evaluator.py @@ -1,5 +1,7 @@ from __future__ import annotations +from tests.quality.tactician_conversations.semantic_assertions import has_concept + def evaluate_turn(payload: dict, expected: dict) -> list[str]: failures: list[str] = [] @@ -7,22 +9,61 @@ def evaluate_turn(payload: dict, expected: dict) -> list[str]: "strategy_intent", "combo_classification", "response_language", + "response_mode", "judge_verified", "answer_complete", + "factual_core_preserved", + "tactician_synthesized", + "origin", ): if field in expected and payload.get(field) != expected[field]: failures.append(f"{field}: expected {expected[field]!r}, got {payload.get(field)!r}") answer = str(payload.get("answer", "")) for phrase in expected.get("required_phrases", []): - if phrase not in answer: + if phrase.casefold() not in answer.casefold(): failures.append(f"missing required phrase: {phrase}") for phrase in expected.get("forbidden_phrases", []): if phrase.casefold() in answer.casefold(): failures.append(f"forbidden phrase present: {phrase}") + for concept in expected.get("required_concepts", []): + if not has_concept(answer, concept): + failures.append(f"missing required concept: {concept}") + for concept in expected.get("forbidden_concepts", []): + if has_concept(answer, concept): + failures.append(f"forbidden concept present: {concept}") + rule_numbers = {str(item.get("number", "")) for item in payload.get("rules", [])} for number in expected.get("required_rules", []): if number not in rule_numbers: failures.append(f"missing required rule: {number}") + + card_names = {str(item.get("name", "")).casefold() for item in payload.get("cards", [])} + for name in expected.get("required_cards", []): + if name.casefold() not in card_names: + failures.append(f"missing required card: {name}") + + tools = {str(item.get("tool", "")) for item in payload.get("judge_tool_calls", [])} + for tool in expected.get("required_tools", []): + if tool not in tools: + failures.append(f"missing required tool: {tool}") + + minimum_coverage = expected.get("minimum_factual_core_coverage") + if minimum_coverage is not None: + coverage = payload.get("factual_core_coverage", {}) + required = int(coverage.get("required", 0)) + covered = int(coverage.get("covered", 0)) + ratio = 1.0 if required == 0 else covered / required + if ratio < float(minimum_coverage): + failures.append( + f"factual core coverage: expected >= {minimum_coverage}, got {covered}/{required}" + ) + + forbidden_trace = expected.get("forbidden_authority_trace", []) + trace = set(payload.get("authority_trace", [])) + for item in forbidden_trace: + if item in trace: + failures.append(f"forbidden authority trace present: {item}") + return failures diff --git a/tests/quality/tactician_conversations/fixtures.py b/tests/quality/tactician_conversations/fixtures.py new file mode 100644 index 0000000..789e41c --- /dev/null +++ b/tests/quality/tactician_conversations/fixtures.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from magicai.judge_tools.models import JudgeToolResult, JudgeToolStatus + + +YOUNG_WOLF = { + "name": "Young Wolf", + "oracle_id": "wolf", + "oracle_text": ( + "Undying (When this creature dies, if it had no +1/+1 counters on it, " + "return it to the battlefield under its owner's control with a +1/+1 counter on it.)" + ), +} +CARRION_FEEDER = { + "name": "Carrion Feeder", + "oracle_id": "feeder", + "oracle_text": "Sacrifice a creature: Put a +1/+1 counter on this creature.", +} +OZOLITH = { + "name": "The Ozolith", + "oracle_id": "ozolith", + "oracle_text": ( + "Whenever a creature you control leaves the battlefield, if it had counters on it, " + "put those counters on The Ozolith." + ), +} +ALTAR = { + "name": "Ashnod's Altar", + "oracle_id": "altar", + "oracle_text": "Sacrifice a creature: Add {C}{C}.", +} +GHAVE = { + "name": "Ghave, Guru of Spores", + "oracle_id": "ghave", + "oracle_text": ( + "Ghave enters with five +1/+1 counters on it.\n" + "{1}, Remove a +1/+1 counter from a creature you control: Create a 1/1 green Saproling creature token.\n" + "{1}, Sacrifice a creature: Put a +1/+1 counter on target creature." + ), +} + +ALL_CARDS = { + card["name"].casefold(): card + for card in (YOUNG_WOLF, CARRION_FEEDER, OZOLITH, ALTAR, GHAVE) +} + + +class ConversationFixtureJudge: + def __init__(self, *, behavior: str = "normal") -> None: + self.behavior = behavior + + def ask_result(self, conversation, question): + cards = self._cards_for_turn(conversation, question) + normalized = question.casefold() + is_strategy = any(marker in normalized for marker in ( + "combo", "bucle", "infinito", "orden", "cort", "interrump", "necesito", "requisitos", + "loop", "sequence", "disrupt", "what do i need", + )) + + if self.behavior == "drift" and any(marker in normalized for marker in ("pisa cementerio", "mismo evento", "muere", "morir", "no simplemente")): + answer = "Cuando un jugador pierde el juego, se comprueban habilidades disparadas por perder la partida." + status = "answered" + origin = "llm_validated" + elif is_strategy and len(cards) >= 2: + answer = "The Judge recovered the factual package and hands strategic synthesis to the Tactician." + status = "strategy_required" + origin = "strategy_boundary" + else: + asks_with_counter = any(marker in normalized for marker in ( + "con un contador", "ya tenía contador", "ya tenia contador", "with a +1/+1 counter", + )) + if asks_with_counter: + answer = ( + "Al sacrificar Young Wolf, pasa del campo de batalla al cementerio y muere. " + "Como ya tenía un contador +1/+1, Undying no se dispara y permanece en el cementerio." + ) + else: + answer = ( + "Al sacrificar Young Wolf, pasa del campo de batalla al cementerio y muere. " + "Si no tenía contadores +1/+1, Undying se dispara y, cuando se resuelve, " + "vuelve al campo de batalla bajo el control de su propietario con un contador +1/+1." + ) + if getattr(conversation, "language", "") == "en": + if asks_with_counter: + answer = ( + "When Young Wolf is sacrificed, it moves from the battlefield to the graveyard and dies. " + "Because it already had a +1/+1 counter, Undying does not trigger and it remains in the graveyard." + ) + else: + answer = ( + "When Young Wolf is sacrificed, it moves from the battlefield to the graveyard and dies. " + "If it had no +1/+1 counters, Undying triggers and returns it with a +1/+1 counter when it resolves." + ) + status = "answered" + origin = "deterministic_rule" + + return SimpleNamespace(to_dict=lambda: { + "schema_version": "1.0", + "question": question, + "answer": answer, + "status": status, + "origin": origin, + "confidence": "high", + "authority": "judge", + "cards": cards, + "rules": [], + "rulings": [], + "retrieval_queries": [], + "assumptions": [], + "warnings": [], + "source_versions": {}, + "source_health": {}, + "validation_attempts": 0, + "reviewed_by": [], + "review_challenges": [], + "llm_called": origin == "llm_validated", + "timings": {}, + }) + + def _cards_for_turn(self, conversation, question: str) -> list[dict]: + normalized = question.casefold() + explicit = [card for name, card in ALL_CARDS.items() if name in normalized] + if explicit: + names = {card["name"] for card in explicit} + if "Ghave, Guru of Spores" in names or "Ashnod's Altar" in names: + return [YOUNG_WOLF, ALTAR, GHAVE] + if "Carrion Feeder" in names or "The Ozolith" in names: + return [YOUNG_WOLF, CARRION_FEEDER, OZOLITH] + return explicit + if conversation.active_cards: + return list(conversation.active_cards) + return [YOUNG_WOLF] + + @staticmethod + def _english(question: str) -> bool: + normalized = question.casefold() + return any(marker in normalized for marker in ("what happens", "if i", "does undying", "why does")) + + +class ConversationFixtureGateway: + def execute(self, request, *, conversation=None, budget=None): + if request.tool == "oracle_lookup": + evidence = [ + {"kind": "card", "identifier": ALL_CARDS[name.casefold()]["oracle_id"], "data": ALL_CARDS[name.casefold()]} + for name in request.arguments.get("card_names", []) + if name.casefold() in ALL_CARDS + ] + authority = "official_card_data" + elif request.tool == "rules_lookup": + evidence = [ + { + "kind": "rule", + "identifier": identifier, + "data": {"number": identifier, "title": f"Rule {identifier}", "rules": []}, + } + for identifier in request.arguments.get("identifiers", []) + ] + authority = "official_rules" + elif request.tool == "rulings_lookup": + evidence = [{ + "kind": "ruling", + "identifier": "ozolith:2020-04-17", + "data": { + "oracle_id": "ozolith", + "card_name": "The Ozolith", + "published_at": "2020-04-17", + "source": "wotc", + "comment": "The Ozolith does not move counters off the creature that left the battlefield.", + }, + }] + authority = "official_rulings" + else: + raise AssertionError(f"unexpected fixture tool: {request.tool}") + + return JudgeToolResult( + tool=request.tool, + status=JudgeToolStatus.SUCCESS, + authority=authority, + provider="conversation_fixture", + purpose=request.purpose, + arguments=request.arguments, + evidence=evidence, + budget=budget.snapshot() if budget else {}, + ) diff --git a/tests/quality/tactician_conversations/loader.py b/tests/quality/tactician_conversations/loader.py index 14013ef..d37ae2e 100644 --- a/tests/quality/tactician_conversations/loader.py +++ b/tests/quality/tactician_conversations/loader.py @@ -5,8 +5,27 @@ def load_scenarios(path: str | Path) -> list[dict]: - payload = json.loads(Path(path).read_text(encoding="utf-8")) + target = Path(path) + if target.is_dir(): + scenarios: list[dict] = [] + for file_path in sorted(target.glob("*.json")): + scenarios.extend(_load_file(file_path)) + assert scenarios, f"no Tactician conversation cases found in {target}" + return scenarios + return _load_file(target) + + +def _load_file(path: Path) -> list[dict]: + payload = json.loads(path.read_text(encoding="utf-8")) assert payload.get("schema_version") == "1.0" scenarios = payload.get("scenarios") assert isinstance(scenarios, list) and scenarios + for scenario in scenarios: + assert str(scenario.get("id", "")).strip() + assert scenario.get("language") in {"es", "en"} + turns = scenario.get("turns") + assert isinstance(turns, list) and turns + for turn in turns: + assert str(turn.get("question", "")).strip() + assert isinstance(turn.get("expect", {}), dict) return scenarios diff --git a/tests/quality/tactician_conversations/report.py b/tests/quality/tactician_conversations/report.py new file mode 100644 index 0000000..6a160ad --- /dev/null +++ b/tests/quality/tactician_conversations/report.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import html +import json +from pathlib import Path + + +def write_reports(results: dict, output_dir: str | Path) -> tuple[Path, Path]: + output = Path(output_dir) + output.mkdir(parents=True, exist_ok=True) + json_path = output / "summary.json" + html_path = output / "report.html" + json_path.write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8") + html_path.write_text(_render_html(results), encoding="utf-8") + return json_path, html_path + + +def _render_html(results: dict) -> str: + rows: list[str] = [] + for scenario in results.get("scenarios", []): + for turn in scenario.get("turns", []): + failures = "
".join(html.escape(item) for item in turn.get("failures", [])) or "—" + rows.append( + "" + f"{html.escape(str(scenario.get('id', '')))}" + f"{turn.get('turn', '')}" + f"{html.escape(str(turn.get('question', '')))}" + f"{'PASS' if turn.get('passed') else 'FAIL'}" + f"{failures}" + "" + ) + return """ + +MagicAI Tactician Conversation Gauntlet + + +

MagicAI Tactician Conversation Gauntlet

+

Scenarios: %s · Turns: %s · Passed: %s · Failed: %s

+ +%s
ScenarioTurnQuestionResultFailures
+""" % ( + results.get("scenario_count", 0), + results.get("turn_count", 0), + results.get("passed_turns", 0), + results.get("failed_turns", 0), + "".join(rows), + ) diff --git a/tests/quality/tactician_conversations/runner.py b/tests/quality/tactician_conversations/runner.py new file mode 100644 index 0000000..396c6a0 --- /dev/null +++ b/tests/quality/tactician_conversations/runner.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import argparse +from pathlib import Path +from typing import Any + +from magicai.conversation.models import Conversation +from magicai.tactician.core import Tactician +from tests.quality.tactician_conversations.evaluator import evaluate_turn +from tests.quality.tactician_conversations.fixtures import ( + ConversationFixtureGateway, + ConversationFixtureJudge, +) +from tests.quality.tactician_conversations.loader import load_scenarios +from tests.quality.tactician_conversations.report import write_reports + + +def run_gauntlet( + cases: str | Path, + *, + mode: str = "fixture", + output_dir: str | Path | None = None, +) -> dict[str, Any]: + scenarios = load_scenarios(cases) + scenario_results: list[dict[str, Any]] = [] + turn_count = 0 + passed_turns = 0 + + for scenario in scenarios: + conversation = Conversation(language=scenario.get("language", "es")) + tactician = _build_tactician(mode, behavior=scenario.get("judge_behavior", "normal")) + turns: list[dict[str, Any]] = [] + for index, turn in enumerate(scenario["turns"], start=1): + payload = tactician.ask_result(conversation, turn["question"]).to_dict() + failures = evaluate_turn(payload, turn.get("expect", {})) + passed = not failures + turn_count += 1 + passed_turns += int(passed) + turns.append({ + "turn": index, + "question": turn["question"], + "passed": passed, + "failures": failures, + "answer": payload.get("answer", ""), + "strategy_intent": payload.get("strategy_intent", ""), + "response_mode": payload.get("response_mode", ""), + "combo_classification": payload.get("combo_classification", ""), + }) + scenario_results.append({ + "id": scenario["id"], + "title": scenario.get("title", ""), + "passed": all(turn["passed"] for turn in turns), + "turns": turns, + }) + + results = { + "schema_version": "1.0", + "mode": mode, + "scenario_count": len(scenario_results), + "turn_count": turn_count, + "passed_turns": passed_turns, + "failed_turns": turn_count - passed_turns, + "passed_scenarios": sum(int(item["passed"]) for item in scenario_results), + "failed_scenarios": sum(int(not item["passed"]) for item in scenario_results), + "scenarios": scenario_results, + } + if output_dir is not None: + write_reports(results, output_dir) + return results + + +def _build_tactician(mode: str, *, behavior: str) -> Tactician: + if mode == "fixture": + return Tactician( + judge=ConversationFixtureJudge(behavior=behavior), + tool_gateway=ConversationFixtureGateway(), + ) + if mode == "local": + return Tactician() + raise ValueError(f"unsupported gauntlet mode: {mode}") + + +def main() -> int: + parser = argparse.ArgumentParser(description="Run the MagicAI Tactician conversational gauntlet.") + parser.add_argument("--cases", default="tests/quality/cases/tactician_conversations") + parser.add_argument("--mode", choices=("fixture", "local"), default="fixture") + parser.add_argument("--output-dir", default="quality-results/tactician-conversations") + args = parser.parse_args() + + results = run_gauntlet(args.cases, mode=args.mode, output_dir=args.output_dir) + print( + "Tactician conversation gauntlet: " + f"{results['passed_scenarios']}/{results['scenario_count']} scenarios, " + f"{results['passed_turns']}/{results['turn_count']} turns passed" + ) + return 0 if results["failed_turns"] == 0 else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/quality/tactician_conversations/semantic_assertions.py b/tests/quality/tactician_conversations/semantic_assertions.py new file mode 100644 index 0000000..01213e5 --- /dev/null +++ b/tests/quality/tactician_conversations/semantic_assertions.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import re +import unicodedata + + +_CONCEPT_MARKERS: dict[str, tuple[tuple[str, ...], ...]] = { + "battlefield_to_graveyard": ( + ("campo de batalla", "cementerio"), + ("battlefield", "graveyard"), + ), + "dies": (("muere",), ("morir",), ("dies",)), + "undying_without_counter": ( + ("undying", "se dispara", "no tenia", "contador"), + ("undying", "se dispara", "sin contador"), + ("undying", "triggers", "no +1/+1 counter"), + ("undying", "triggers", "had no +1/+1"), + ), + "returns_with_counter": ( + ("vuelve", "contador +1/+1"), + ("regresa", "contador +1/+1"), + ("returns", "+1/+1 counter"), + ), + "undying_blocked_by_counter": ( + ("contador +1/+1", "undying no se dispara"), + ("undying", "contador", "no se dispara"), + ("tenia", "contador", "no se dispara"), + ("had a +1/+1 counter", "does not trigger"), + ), + "same_event": ( + ("mismo evento",), + ("no son dos momentos",), + ("same event",), + ("not two separate moments",), + ), + "other_zone_not_dies": ( + ("otra zona", "no", "morir"), + ("mano", "biblioteca", "no"), + ("another zone", "not dying"), + ("hand", "library", "did not die"), + ), + "ozolith_does_not_reset": ( + ("ozolith", "no reinicia undying"), + ("ozolith", "no", "retira", "contador"), + ("ozolith", "no retiro", "contador"), + ("ozolith", "does not reset undying"), + ("ozolith", "does not remove", "counter"), + ), + "non_infinite_loop": ( + ("no forman", "bucle infinito"), + ("no es", "combo infinito"), + ("do not form", "infinite loop"), + ), + "ghave_resets_counter": ( + ("ghave", "retira", "contador"), + ("ghave", "remove", "counter"), + ), + "altar_produces_two": ( + ("ashnod", "dos manas"), + ("ashnod", "dos manás"), + ("ashnod", "two", "mana"), + ), + "net_mana_and_token": ( + ("mana", "neto", "saproling"), + ("maná", "neto", "saproling"), + ("mana", "neto", "ficha"), + ("maná", "neto", "ficha"), + ("net", "mana", "saproling"), + ("net", "mana", "token"), + ), + "graveyard_interruption": ( + ("exiliar", "young wolf", "cementerio"), + ("exile", "young wolf", "graveyard"), + ), + "ghave_removal_window": ( + ("eliminar", "ghave"), + ("removal", "ghave"), + ("remove", "ghave"), + ), + "requires_three_pieces": ( + ("necesitas", "young wolf", "ashnod", "ghave"), + ("need", "young wolf", "ashnod", "ghave"), + ), + "no_starting_mana": ( + ("no necesitas mana inicial",), + ("no necesitas maná inicial",), + ("no starting mana",), + ), +} + + +def has_concept(text: str, concept: str) -> bool: + normalized = _normalize(text) + groups = _CONCEPT_MARKERS.get(concept) + if groups is None: + raise KeyError(f"unknown semantic concept: {concept}") + return any(all(_normalize(marker) in normalized for marker in group) for group in groups) + + +def known_concepts() -> set[str]: + return set(_CONCEPT_MARKERS) + + +def _normalize(text: str) -> str: + value = unicodedata.normalize("NFKD", text or "") + value = "".join(char for char in value if not unicodedata.combining(char)) + return re.sub(r"\s+", " ", value.casefold()).strip() diff --git a/tests/quality/tactician_feedback_promotion_test.py b/tests/quality/tactician_feedback_promotion_test.py new file mode 100644 index 0000000..a18d50a --- /dev/null +++ b/tests/quality/tactician_feedback_promotion_test.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import json +from pathlib import Path +from tempfile import TemporaryDirectory + +from scripts.promote_tactician_feedback import build_candidate, promote_file + + +EXPORT = { + "schema_version": "0.5", + "question": "¿Qué ocurre si sacrifico Young Wolf?", + "answer": "Observed answer.", + "origin": "tactician_judge_led", + "strategy_intent": "mechanic_resolution", + "response_mode": "judge_led", + "combo_classification": "not_applicable", + "response_language": "es", + "cards": [{"name": "Young Wolf"}], + "rules": [{"number": "701.21a"}, {"number": "700.4"}], + "judge_tool_calls": [{"tool": "oracle_lookup"}, {"tool": "rules_lookup"}], +} + + +def test_candidate_requires_human_review() -> None: + candidate = build_candidate(EXPORT, candidate_id="TACT-CANDIDATE-001") + item = candidate["candidate"] + assert item["review_required"] is True + turn = item["turns"][0] + assert turn["expect"]["review_required"] is True + assert turn["expect"]["strategy_intent"] == "mechanic_resolution" + assert turn["expect"]["required_cards"] == ["Young Wolf"] + assert "semantic requirements" in turn["review_notes"][0] + + +def test_promotion_writes_candidate_outside_active_corpus() -> None: + with TemporaryDirectory() as directory: + source = Path(directory) / "export.json" + source.write_text(json.dumps(EXPORT), encoding="utf-8") + target = promote_file(source, Path(directory) / "candidates", candidate_id="TACT-CANDIDATE-002") + payload = json.loads(target.read_text(encoding="utf-8")) + assert target.name.endswith(".candidate.json") + assert payload["candidate"]["id"] == "TACT-CANDIDATE-002" + assert payload["candidate"]["review_required"] is True + + +def main() -> int: + tests = [test_candidate_requires_human_review, test_promotion_writes_candidate_outside_active_corpus] + for test in tests: + test() + print(f"OK: {test.__name__}") + print(f"Tactician feedback promotion tests: {len(tests)}/{len(tests)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/tactician/tactician_core_test.py b/tests/tactician/tactician_core_test.py index 2d2e187..6e4dc82 100644 --- a/tests/tactician/tactician_core_test.py +++ b/tests/tactician/tactician_core_test.py @@ -106,7 +106,8 @@ def test_tactician_consumes_judge_package_without_direct_sources() -> None: "tactician:language_policy", "tactician:input_analysis", "tactician:claim_evaluation", - "tactician:strategic_synthesis", + "tactician:response_orchestration:tactician_led", + "tactician:factual_core_preservation", "tactician:answer_contract", "judge:evidence_verification", ] diff --git a/tests/tactician/tactician_input_reasoning_test.py b/tests/tactician/tactician_input_reasoning_test.py index 7c40fcf..c9c16e8 100644 --- a/tests/tactician/tactician_input_reasoning_test.py +++ b/tests/tactician/tactician_input_reasoning_test.py @@ -122,6 +122,7 @@ def test_three_card_question_explains_why_ozolith_is_not_a_reset() -> None: payload = result.to_dict() assert payload["combo_classification"] == "non_combo" + assert payload["response_mode"] == "tactician_led" assert payload["tactician_synthesized"] is True assert payload["judge_verified"] is True assert payload["queries_completed"] >= 3 @@ -143,7 +144,8 @@ def test_challenge_is_evaluated_instead_of_relaying_judge_answer() -> None: ) payload = result.to_dict() - assert payload["origin"] == "tactician_reasoned_strategy" + assert payload["origin"] == "tactician_hybrid" + assert payload["response_mode"] == "hybrid" assert payload["tactician_synthesized"] is True assert payload["input_analysis"]["speech_act"] == "challenge" assert payload["strategy_intent"] == "interaction_hypothesis" diff --git a/tests/tactician/tactician_response_orchestration_test.py b/tests/tactician/tactician_response_orchestration_test.py new file mode 100644 index 0000000..5dfc817 --- /dev/null +++ b/tests/tactician/tactician_response_orchestration_test.py @@ -0,0 +1,259 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from magicai.conversation.models import Conversation +from magicai.judge_tools.models import JudgeToolResult, JudgeToolStatus +from magicai.tactician.core import Tactician + + +YOUNG_WOLF = { + "name": "Young Wolf", + "oracle_id": "wolf", + "oracle_text": ( + "Undying (When this creature dies, if it had no +1/+1 counters on it, " + "return it to the battlefield under its owner's control with a +1/+1 counter on it.)" + ), +} +CARRION_FEEDER = { + "name": "Carrion Feeder", + "oracle_id": "feeder", + "oracle_text": "Sacrifice a creature: Put a +1/+1 counter on this creature.", +} +GENERIC_CARD = { + "name": "Example Adept", + "oracle_id": "example", + "oracle_text": "Whenever this creature attacks, draw a card.", +} +OZOLITH = { + "name": "The Ozolith", + "oracle_id": "ozolith", + "oracle_text": ( + "Whenever a creature you control leaves the battlefield, if it had counters on it, " + "put those counters on The Ozolith." + ), +} + + +class FixtureJudge: + def __init__(self, *, drift: bool = False) -> None: + self.drift = drift + + def ask_result(self, conversation, question): + if "Carrion" in question or "Ozolith" in question or conversation.active_cards: + cards = list(conversation.active_cards or [YOUNG_WOLF, CARRION_FEEDER, OZOLITH]) + else: + cards = [YOUNG_WOLF] + answer = ( + "Cuando un jugador pierde el juego, se comprueban habilidades disparadas." + if self.drift + else ( + "Al sacrificar Young Wolf, pasa del campo de batalla al cementerio y muere. " + "Si no tenía contadores +1/+1, Undying se dispara y vuelve con un contador +1/+1." + ) + ) + return SimpleNamespace(to_dict=lambda: { + "schema_version": "1.0", + "question": question, + "answer": answer, + "status": "answered", + "origin": "llm_validated" if self.drift else "deterministic_rule", + "confidence": "high", + "authority": "judge", + "cards": cards, + "rules": [], + "rulings": [], + "retrieval_queries": [], + "assumptions": [], + "warnings": [], + "source_versions": {}, + "source_health": {}, + "validation_attempts": 0, + "reviewed_by": [], + "review_challenges": [], + "llm_called": self.drift, + "timings": {}, + }) + + +class FixtureGateway: + def execute(self, request, *, conversation=None, budget=None): + if request.tool == "oracle_lookup": + lookup = { + "young wolf": YOUNG_WOLF, + "carrion feeder": CARRION_FEEDER, + "the ozolith": OZOLITH, + "example adept": GENERIC_CARD, + } + evidence = [ + {"kind": "card", "identifier": lookup[name.casefold()]["oracle_id"], "data": lookup[name.casefold()]} + for name in request.arguments.get("card_names", []) + if name.casefold() in lookup + ] + authority = "official_card_data" + elif request.tool == "rules_lookup": + evidence = [ + { + "kind": "rule", + "identifier": identifier, + "data": {"number": identifier, "title": f"Rule {identifier}", "rules": []}, + } + for identifier in request.arguments.get("identifiers", []) + ] + authority = "official_rules" + elif request.tool == "rulings_lookup": + evidence = [{ + "kind": "ruling", + "identifier": "ozolith:2020-04-17", + "data": { + "oracle_id": "ozolith", + "card_name": "The Ozolith", + "published_at": "2020-04-17", + "source": "wotc", + "comment": "The Ozolith does not move counters off the creature that left the battlefield.", + }, + }] + authority = "official_rulings" + else: + raise AssertionError(request.tool) + return JudgeToolResult( + tool=request.tool, + status=JudgeToolStatus.SUCCESS, + authority=authority, + provider="fixture", + purpose=request.purpose, + arguments=request.arguments, + evidence=evidence, + budget=budget.snapshot() if budget else {}, + ) + + +def test_rules_turn_is_judge_led_and_preserves_factual_core() -> None: + conversation = Conversation(language="es") + result = Tactician(judge=FixtureJudge(), tool_gateway=FixtureGateway()).ask_result( + conversation, + "¿Qué ocurre si sacrifico Young Wolf?", + ) + payload = result.to_dict() + + assert payload["strategy_intent"] == "mechanic_resolution" + assert payload["response_mode"] == "judge_led" + assert payload["combo_classification"] == "not_applicable" + assert payload["factual_core_preserved"] is True + assert payload["factual_core_coverage"]["complete"] is True + assert payload["factual_core_coverage"]["covered"] >= 1 + assert payload["answer_complete"] is True + assert payload["judge_verified"] is True + assert "pasa del campo de batalla al cementerio" in payload["answer"] + assert "bucle infinito" not in payload["answer"] + + + + +class GenericRuleJudge: + def ask_result(self, conversation, question): + return SimpleNamespace(to_dict=lambda: { + "schema_version": "1.0", + "question": question, + "answer": "Sí. Una habilidad disparada usa la pila y los jugadores pueden responder antes de que se resuelva.", + "status": "answered", + "origin": "deterministic_rule", + "confidence": "high", + "authority": "judge", + "cards": [GENERIC_CARD], + "rules": [{"number": "603", "title": "Handling Triggered Abilities"}], + "rulings": [], + "retrieval_queries": [], + "assumptions": [], + "warnings": [], + "source_versions": {}, + "source_health": {}, + "validation_attempts": 0, + "reviewed_by": [], + "review_challenges": [], + "llm_called": False, + "timings": {}, + }) + + +def test_judge_led_preservation_is_card_agnostic() -> None: + conversation = Conversation(language="es") + result = Tactician(judge=GenericRuleJudge(), tool_gateway=FixtureGateway()).ask_result( + conversation, + "¿Se puede responder a la habilidad disparada de Example Adept?", + ) + payload = result.to_dict() + + assert payload["response_mode"] == "judge_led" + assert payload["combo_classification"] == "not_applicable" + assert payload["factual_core_preserved"] is True + assert payload["answer"] == payload["judge_result"]["answer"] + assert payload["factual_core_coverage"]["complete"] is True + +def test_drifted_judge_answer_is_not_relayed() -> None: + conversation = Conversation( + language="es", + active_cards=[YOUNG_WOLF, CARRION_FEEDER, OZOLITH], + strategy_context={"combo_classification": "non_combo"}, + ) + result = Tactician(judge=FixtureJudge(drift=True), tool_gateway=FixtureGateway()).ask_result( + conversation, + "Entonces, ¿Undying es cuando muere la criatura, no cuando pisa cementerio?", + ) + payload = result.to_dict() + + assert payload["response_mode"] == "judge_led" + assert payload["strategy_intent"] == "mechanic_equivalence" + assert payload["combo_classification"] == "non_combo" + assert payload["factual_core_preserved"] is True + assert payload["answer_complete"] is True + assert "mismo evento" in payload["answer"] + assert "jugador pierde el juego" not in payload["answer"] + + +def test_combo_question_remains_tactician_led() -> None: + conversation = Conversation(language="es", active_cards=[YOUNG_WOLF, CARRION_FEEDER, OZOLITH]) + result = Tactician(judge=FixtureJudge(), tool_gateway=FixtureGateway()).ask_result( + conversation, + "¿Young Wolf, Carrion Feeder y The Ozolith hacen combo?", + ) + payload = result.to_dict() + + assert payload["response_mode"] == "tactician_led" + assert payload["strategy_intent"] == "combo_detection" + assert payload["combo_classification"] == "non_combo" + assert payload["tactician_synthesized"] is True + + +def test_incorrect_combo_hypothesis_is_hybrid() -> None: + conversation = Conversation(language="es", active_cards=[YOUNG_WOLF, CARRION_FEEDER, OZOLITH]) + result = Tactician(judge=FixtureJudge(), tool_gateway=FixtureGateway()).ask_result( + conversation, + "Pero The Ozolith se lleva el contador antes, por lo que Undying se dispara otra vez.", + ) + payload = result.to_dict() + + assert payload["response_mode"] == "hybrid" + assert payload["strategy_intent"] == "interaction_hypothesis" + assert payload["combo_classification"] == "non_combo" + assert any(item["verdict"] == "contradicted" for item in payload["claim_verdicts"]) + assert "Undying no se dispara" in " ".join(payload["combo_steps"]) + + +def main() -> int: + tests = [ + test_rules_turn_is_judge_led_and_preserves_factual_core, + test_judge_led_preservation_is_card_agnostic, + test_drifted_judge_answer_is_not_relayed, + test_combo_question_remains_tactician_led, + test_incorrect_combo_hypothesis_is_hybrid, + ] + for test in tests: + test() + print(f"OK: {test.__name__}") + print(f"Tactician response orchestration tests: {len(tests)}/{len(tests)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From c1f53e3dbfcbdf9fc8101fd65ca0017cbdbc0b38 Mon Sep 17 00:00:00 2001 From: Manuel David Villalba Escamilla Date: Fri, 17 Jul 2026 02:26:07 +0200 Subject: [PATCH 7/7] fix(tactician): align deterministic rule metadata --- .github/PULL_REQUEST_TEMPLATE.md | 70 +-- .github/dependabot.yml | 32 +- .github/workflows/ci.yml | 106 ++-- CODE_OF_CONDUCT.md | 56 +- SECURITY.md | 62 +- SUPPORT.md | 46 +- docs/API_CONTRACT.md | 9 +- docs/BRANCHING.md | 132 ++--- docs/CONTRIBUTING.md | 80 +-- docs/JUDGE_TOOL_GATEWAY.md | 33 +- docs/QUICKSTART.md | 146 ++--- docs/RELEASE_PROCESS.md | 150 ++--- docs/REPOSITORY_HEALTH.md | 102 ++-- docs/ROADMAP.md | 47 +- docs/STATUS.md | 50 +- docs/TACTICIAN.md | 2 +- docs/TACTICIAN_REASONING.md | 37 +- magicai/api/health.py | 242 ++++---- magicai/api/schemas.py | 1 + magicai/retrieval/concept_evidence.py | 73 +++ magicai/retrieval/rule_queries.py | 40 +- magicai/tactician/answer_contract.py | 134 +++++ magicai/tactician/core.py | 126 ++-- magicai/tactician/input_analysis.py | 10 +- magicai/tactician/intents.py | 4 + magicai/tactician/investigation.py | 555 ++++++++++++++++++ magicai/tactician/models.py | 2 + magicai/tactician/planner.py | 57 +- magicai/validation/rule_renderer.py | 317 ++++++++++ magicai/versioning.py | 4 +- pyproject.toml | 52 +- scripts/ci_check.py | 3 + tests/api/api_contract_metadata_test.py | 180 +++--- tests/api/tactician_api_contract_test.py | 6 + tests/repository/__init__.py | 2 +- tests/repository/release_packaging_test.py | 240 ++++---- tests/repository/repository_health_test.py | 236 ++++---- ...tactician_autonomous_investigation_test.py | 155 +++++ tests/tactician/tactician_core_test.py | 3 + ...land_type_investigation_regression_test.py | 249 ++++++++ .../rule_renderer_land_type_layers_test.py | 164 ++++++ 41 files changed, 2940 insertions(+), 1075 deletions(-) create mode 100644 magicai/tactician/investigation.py create mode 100644 tests/tactician/tactician_autonomous_investigation_test.py create mode 100644 tests/tactician/tactician_land_type_investigation_regression_test.py create mode 100644 tests/validation/rule_renderer_land_type_layers_test.py diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 1724d7f..70a090a 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,35 +1,35 @@ -## Summary - -Describe what changed and why. - -## Scope - -- [ ] Application behavior -- [ ] Judge or factual authority -- [ ] Strategist behavior -- [ ] Source or retrieval logic -- [ ] API or UI contract -- [ ] Tests or quality infrastructure -- [ ] Documentation or repository health - -## Source-grounding impact - -Explain whether this change affects Oracle, rules, rulings, legality, source provenance, or the Judge–Strategist authority boundary. - -## Validation - -List the commands and campaigns executed. - -```text -python scripts/ci_check.py -``` - -- [ ] Focused tests pass -- [ ] `git diff --check` passes -- [ ] No large generated sources, reports, databases, logs, or secrets were added -- [ ] New behavior has a regression test -- [ ] Markdown documentation is written in English - -## Known limitations - -Document anything that was not tested or remains intentionally out of scope. +## Summary + +Describe what changed and why. + +## Scope + +- [ ] Application behavior +- [ ] Judge or factual authority +- [ ] Strategist behavior +- [ ] Source or retrieval logic +- [ ] API or UI contract +- [ ] Tests or quality infrastructure +- [ ] Documentation or repository health + +## Source-grounding impact + +Explain whether this change affects Oracle, rules, rulings, legality, source provenance, or the Judge–Strategist authority boundary. + +## Validation + +List the commands and campaigns executed. + +```text +python scripts/ci_check.py +``` + +- [ ] Focused tests pass +- [ ] `git diff --check` passes +- [ ] No large generated sources, reports, databases, logs, or secrets were added +- [ ] New behavior has a regression test +- [ ] Markdown documentation is written in English + +## Known limitations + +Document anything that was not tested or remains intentionally out of scope. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f362582..4505532 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,16 +1,16 @@ -version: 2 - -updates: - - package-ecosystem: "uv" - directory: "/" - target-branch: "develop" - schedule: - interval: "weekly" - open-pull-requests-limit: 5 - - - package-ecosystem: "github-actions" - directory: "/" - target-branch: "develop" - schedule: - interval: "weekly" - open-pull-requests-limit: 5 +version: 2 + +updates: + - package-ecosystem: "uv" + directory: "/" + target-branch: "develop" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + + - package-ecosystem: "github-actions" + directory: "/" + target-branch: "develop" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a6c6df..c7f5b37 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,53 +1,53 @@ -name: CI - -on: - pull_request: - branches: - - main - - develop - push: - branches: - - main - - develop - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - focused-tests: - name: Focused tests (Python 3.12) - runs-on: ubuntu-latest - timeout-minutes: 15 - - steps: - - name: Check out repository - uses: actions/checkout@v7 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.12" - - - name: Install uv - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 - with: - enable-cache: true - - - name: Validate lock file - run: uv lock --check - - - name: Install locked dependencies - run: | - uv sync --frozen - uv pip check - - - name: Run repository and focused application checks - env: - MAGICAI_QUIET_EVALUATION: "1" - MAGICAI_CONVERSATION_DB: ${{ runner.temp }}/magicai-ci-conversations.sqlite3 - run: uv run --frozen python scripts/ci_check.py +name: CI + +on: + pull_request: + branches: + - main + - develop + push: + branches: + - main + - develop + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + focused-tests: + name: Focused tests (Python 3.12) + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install uv + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + with: + enable-cache: true + + - name: Validate lock file + run: uv lock --check + + - name: Install locked dependencies + run: | + uv sync --frozen + uv pip check + + - name: Run repository and focused application checks + env: + MAGICAI_QUIET_EVALUATION: "1" + MAGICAI_CONVERSATION_DB: ${{ runner.temp }}/magicai-ci-conversations.sqlite3 + run: uv run --frozen python scripts/ci_check.py diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index e637275..eb0de0f 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,28 +1,28 @@ -# Code of conduct - -## Our standard - -MagicAI welcomes contributors, players, judges, developers, researchers, and documentation writers. Participation must remain respectful, constructive, and safe. - -Expected behavior includes: - -- discussing ideas and code without attacking people; -- giving specific, actionable feedback; -- respecting different experience levels, languages, identities, and play communities; -- acknowledging uncertainty and correcting mistakes openly; -- protecting private information and unpublished vulnerability details; -- crediting the work and sources of others. - -Unacceptable behavior includes harassment, discrimination, threats, deliberate misinformation, sexualized conduct, personal attacks, doxxing, spam, or attempts to pressure maintainers into unsafe releases. - -## Project-specific expectations - -Rules disagreements must be resolved through evidence and current authoritative sources, not status or popularity. Strategic disagreement is welcome when it remains clearly separated from factual authority. - -Evaluation artifacts, community examples, and reported failures must not be repurposed as training data without explicit permission and a documented policy change. - -## Enforcement - -Maintainers may edit, hide, lock, or remove contributions and may temporarily or permanently restrict participation when behavior violates this policy. - -Report conduct concerns privately through the same channel described in [SECURITY.md](SECURITY.md), while clearly identifying the report as a conduct matter rather than a software vulnerability. +# Code of conduct + +## Our standard + +MagicAI welcomes contributors, players, judges, developers, researchers, and documentation writers. Participation must remain respectful, constructive, and safe. + +Expected behavior includes: + +- discussing ideas and code without attacking people; +- giving specific, actionable feedback; +- respecting different experience levels, languages, identities, and play communities; +- acknowledging uncertainty and correcting mistakes openly; +- protecting private information and unpublished vulnerability details; +- crediting the work and sources of others. + +Unacceptable behavior includes harassment, discrimination, threats, deliberate misinformation, sexualized conduct, personal attacks, doxxing, spam, or attempts to pressure maintainers into unsafe releases. + +## Project-specific expectations + +Rules disagreements must be resolved through evidence and current authoritative sources, not status or popularity. Strategic disagreement is welcome when it remains clearly separated from factual authority. + +Evaluation artifacts, community examples, and reported failures must not be repurposed as training data without explicit permission and a documented policy change. + +## Enforcement + +Maintainers may edit, hide, lock, or remove contributions and may temporarily or permanently restrict participation when behavior violates this policy. + +Report conduct concerns privately through the same channel described in [SECURITY.md](SECURITY.md), while clearly identifying the report as a conduct matter rather than a software vulnerability. diff --git a/SECURITY.md b/SECURITY.md index 6abb76e..39cdcc9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,31 +1,31 @@ -# Security policy - -## Supported versions - -MagicAI is under active beta development. Security fixes are applied to the latest published release and the current `develop` branch. Older alpha and beta snapshots are not maintained unless explicitly stated in their release notes. - -## Reporting a vulnerability - -Do not publish exploit details, credentials, private data, or reproducible attack steps in a public issue. - -Use GitHub's private vulnerability reporting feature for this repository when it is available. If private reporting is unavailable, open a minimal public issue asking the maintainers for a private contact channel. Include no sensitive technical details in that issue. - -A useful private report includes: - -- affected version or commit; -- affected component; -- reproduction steps; -- realistic impact; -- suggested mitigation, when known; -- whether the issue involves local files, network exposure, dependencies, or source data. - -## Security boundaries - -MagicAI is designed as a local-first application, but users remain responsible for their deployment environment. - -- Do not expose an unauthenticated Ollama endpoint to the public Internet. -- Do not commit `.env` files, credentials, private decklists, conversation databases, logs, or generated analysis bundles. -- Treat imported community data and user-supplied files as untrusted input. -- Keep Python, Ollama, MagicAI dependencies, and operating-system packages updated. - -Security reports are evaluated separately from ordinary rules-answer accuracy reports. +# Security policy + +## Supported versions + +MagicAI is under active beta development. Security fixes are applied to the latest published release and the current `develop` branch. Older alpha and beta snapshots are not maintained unless explicitly stated in their release notes. + +## Reporting a vulnerability + +Do not publish exploit details, credentials, private data, or reproducible attack steps in a public issue. + +Use GitHub's private vulnerability reporting feature for this repository when it is available. If private reporting is unavailable, open a minimal public issue asking the maintainers for a private contact channel. Include no sensitive technical details in that issue. + +A useful private report includes: + +- affected version or commit; +- affected component; +- reproduction steps; +- realistic impact; +- suggested mitigation, when known; +- whether the issue involves local files, network exposure, dependencies, or source data. + +## Security boundaries + +MagicAI is designed as a local-first application, but users remain responsible for their deployment environment. + +- Do not expose an unauthenticated Ollama endpoint to the public Internet. +- Do not commit `.env` files, credentials, private decklists, conversation databases, logs, or generated analysis bundles. +- Treat imported community data and user-supplied files as untrusted input. +- Keep Python, Ollama, MagicAI dependencies, and operating-system packages updated. + +Security reports are evaluated separately from ordinary rules-answer accuracy reports. diff --git a/SUPPORT.md b/SUPPORT.md index c23b742..7655cb6 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -1,23 +1,23 @@ -# Support - -## Rules and application questions - -Use GitHub Discussions for general usage, architecture, rules-testing methodology, and community development topics when Discussions are available. - -Use GitHub Issues for reproducible bugs and scoped feature requests. Include: - -- MagicAI version or commit; -- operating system and Python version; -- Ollama server and client versions when relevant; -- the exact question or workflow; -- the structured result or exported feedback artifact; -- the expected behavior; -- logs only after removing private data. - -## Security and conduct - -Do not use ordinary issues for vulnerabilities, credentials, private data, or sensitive conduct reports. Follow [SECURITY.md](SECURITY.md) and [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). - -## Project scope - -MagicAI is a community project and not an official Wizards of the Coast, Scryfall, EDHREC, or Commander Spellbook service. The repository cannot provide official tournament rulings or emergency support. +# Support + +## Rules and application questions + +Use GitHub Discussions for general usage, architecture, rules-testing methodology, and community development topics when Discussions are available. + +Use GitHub Issues for reproducible bugs and scoped feature requests. Include: + +- MagicAI version or commit; +- operating system and Python version; +- Ollama server and client versions when relevant; +- the exact question or workflow; +- the structured result or exported feedback artifact; +- the expected behavior; +- logs only after removing private data. + +## Security and conduct + +Do not use ordinary issues for vulnerabilities, credentials, private data, or sensitive conduct reports. Follow [SECURITY.md](SECURITY.md) and [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). + +## Project scope + +MagicAI is a community project and not an official Wizards of the Coast, Scryfall, EDHREC, or Commander Spellbook service. The repository cannot provide official tournament rulings or emergency support. diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index d4a74fc..dd16633 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -1,11 +1,11 @@ # API contract -Current API contract version: `1.7`. +Current API contract version: `1.8`. ## Stable result families - Judge results use JudgeResult schema `1.0`. -- Tactician results use TacticianResult schema `0.6`. +- Tactician results use TacticianResult schema `0.7`. - Judge tool results use JudgeToolResult schema `1.0`. ## Main endpoints @@ -55,7 +55,7 @@ Unavailable capabilities return a structured result instead of being guessed. ## Tactician reasoning fields -TacticianResult `0.6` includes: +TacticianResult `0.7` includes: - `input_analysis` - `claim_verdicts` @@ -64,6 +64,7 @@ TacticianResult `0.6` includes: - `queries_completed` - `judge_verified` - `investigation_plan` +- `investigation_trace` - `response_language` - `language_policy` - `answer_obligations` @@ -78,4 +79,4 @@ TacticianResult `0.6` includes: `response_mode` is one of `judge_led`, `tactician_led`, or `hybrid`. A Judge-led response preserves the factual answer core; a Tactician-led response performs strategic synthesis; a hybrid response combines a Judge-owned rules explanation with a Tactician-owned strategic conclusion. -These fields expose a concise, structured audit trail. They are not a hidden chain-of-thought transcript. `answer_complete` means the generated answer satisfied its deterministic semantic obligations; `judge_verified` additionally requires supporting Judge-owned evidence. +`investigation_trace` contains hypothesis requirements, sufficiency scores, bounded request phases, and stop reasons. These fields expose a concise, structured audit trail. They are not a hidden chain-of-thought transcript. `answer_complete` means the generated answer satisfied its deterministic semantic obligations; `judge_verified` additionally requires supporting Judge-owned evidence. diff --git a/docs/BRANCHING.md b/docs/BRANCHING.md index b68cec5..42f18d2 100644 --- a/docs/BRANCHING.md +++ b/docs/BRANCHING.md @@ -1,66 +1,66 @@ -# Branching policy - -MagicAI uses a simple release-oriented branching model. - -## Permanent branches - -### `main` - -`main` contains published releases and release candidates that are ready to be presented to users. Tags are created from `main`. - -### `develop` - -`develop` is the integration branch for the next development cycle and the preferred default branch while MagicAI remains under active beta development. - -## Short-lived branches - -Use a focused branch for each change: - -```text -feature/ -fix/ -chore/ -docs/ -test/ -``` - -Examples: - -```text -feature/sprint12-2-judge-tool-gateway -fix/undying-evidence-contract -chore/repository-health-foundation -``` - -Open pull requests against `develop` unless the change is an urgent release fix. - -## Release flow - -1. Merge completed feature, fix, test, and chore branches into `develop`. -2. Run the required CI and release validation on `develop`. -3. Open a release pull request from `develop` to `main`. -4. Merge the release pull request without rewriting public history. -5. Create the canonical release tag from `main`. -6. Merge any release-only corrections from `main` back into `develop`. - -## Hotfix flow - -Urgent corrections to a published release may branch from `main` using `fix/` or `hotfix/`. After release, merge the hotfix back into `develop`. - -## Branch cleanup - -Delete merged short-lived branches after their pull request is complete. Preserve important milestones through tags, releases, changelogs, and documented commits rather than permanent sprint or backup branches. - -Local safety branches may be created before risky operations, but they should not normally be pushed to the shared repository. Remove them after the operation has been verified. - -## Protection recommendations - -Protect `main` and `develop` with: - -- required pull requests; -- required CI checks; -- blocked force pushes; -- blocked deletion; -- resolved review conversations before merge. - -A sole maintainer may temporarily keep review requirements lightweight, but CI and history protection should remain enabled. +# Branching policy + +MagicAI uses a simple release-oriented branching model. + +## Permanent branches + +### `main` + +`main` contains published releases and release candidates that are ready to be presented to users. Tags are created from `main`. + +### `develop` + +`develop` is the integration branch for the next development cycle and the preferred default branch while MagicAI remains under active beta development. + +## Short-lived branches + +Use a focused branch for each change: + +```text +feature/ +fix/ +chore/ +docs/ +test/ +``` + +Examples: + +```text +feature/sprint12-2-judge-tool-gateway +fix/undying-evidence-contract +chore/repository-health-foundation +``` + +Open pull requests against `develop` unless the change is an urgent release fix. + +## Release flow + +1. Merge completed feature, fix, test, and chore branches into `develop`. +2. Run the required CI and release validation on `develop`. +3. Open a release pull request from `develop` to `main`. +4. Merge the release pull request without rewriting public history. +5. Create the canonical release tag from `main`. +6. Merge any release-only corrections from `main` back into `develop`. + +## Hotfix flow + +Urgent corrections to a published release may branch from `main` using `fix/` or `hotfix/`. After release, merge the hotfix back into `develop`. + +## Branch cleanup + +Delete merged short-lived branches after their pull request is complete. Preserve important milestones through tags, releases, changelogs, and documented commits rather than permanent sprint or backup branches. + +Local safety branches may be created before risky operations, but they should not normally be pushed to the shared repository. Remove them after the operation has been verified. + +## Protection recommendations + +Protect `main` and `develop` with: + +- required pull requests; +- required CI checks; +- blocked force pushes; +- blocked deletion; +- resolved review conversations before merge. + +A sole maintainer may temporarily keep review requirements lightweight, but CI and history protection should remain enabled. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index b5b8c2b..a834a65 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -1,40 +1,40 @@ -# Contributing to MagicAI - -## Principles - -- Keep the Judge as the sole factual authority. -- Keep strategic source access behind the Judge gateway. -- Prefer generic semantic fixes over card-name conditions. -- Add focused tests for every repaired family. -- Preserve source provenance and version information. -- Treat evaluation artifacts as evaluation only. - -## Development flow - -```text -main published or stable work -develop integrated development -feature/* isolated sprint branches -``` - -Before opening a pull request: - -```bash -python scripts/ci_check.py -git status --short -``` - -Run the focused modules related to the change and document anything that could not be tested in the local environment. - -Contributions are expected to use English for Markdown documentation and code-facing messages unless the output is intentionally localized for users. - - -## Branches and releases - -Open ordinary changes against `develop`. Published releases are promoted to `main`. See [BRANCHING.md](BRANCHING.md) and [RELEASE_PROCESS.md](RELEASE_PROCESS.md). - -## Pull requests - -Keep pull requests focused, explain source-authority impact, list the tests actually executed, and document limitations. New behavior should include a regression test. Generated sources, reports, logs, databases, analysis bundles, and private data must not be committed. - -All contributors must follow [../CODE_OF_CONDUCT.md](../CODE_OF_CONDUCT.md), [../SECURITY.md](../SECURITY.md), and [../SUPPORT.md](../SUPPORT.md). +# Contributing to MagicAI + +## Principles + +- Keep the Judge as the sole factual authority. +- Keep strategic source access behind the Judge gateway. +- Prefer generic semantic fixes over card-name conditions. +- Add focused tests for every repaired family. +- Preserve source provenance and version information. +- Treat evaluation artifacts as evaluation only. + +## Development flow + +```text +main published or stable work +develop integrated development +feature/* isolated sprint branches +``` + +Before opening a pull request: + +```bash +python scripts/ci_check.py +git status --short +``` + +Run the focused modules related to the change and document anything that could not be tested in the local environment. + +Contributions are expected to use English for Markdown documentation and code-facing messages unless the output is intentionally localized for users. + + +## Branches and releases + +Open ordinary changes against `develop`. Published releases are promoted to `main`. See [BRANCHING.md](BRANCHING.md) and [RELEASE_PROCESS.md](RELEASE_PROCESS.md). + +## Pull requests + +Keep pull requests focused, explain source-authority impact, list the tests actually executed, and document limitations. New behavior should include a regression test. Generated sources, reports, logs, databases, analysis bundles, and private data must not be committed. + +All contributors must follow [../CODE_OF_CONDUCT.md](../CODE_OF_CONDUCT.md), [../SECURITY.md](../SECURITY.md), and [../SUPPORT.md](../SUPPORT.md). diff --git a/docs/JUDGE_TOOL_GATEWAY.md b/docs/JUDGE_TOOL_GATEWAY.md index cfef309..de2bc33 100644 --- a/docs/JUDGE_TOOL_GATEWAY.md +++ b/docs/JUDGE_TOOL_GATEWAY.md @@ -98,6 +98,35 @@ Conversation context is never cached. ## Tactician planner use -Sprint 12.2b can group several bounded requests under one investigation budget. The first implemented plans combine Oracle refresh, exact rules lookup, and official rulings lookup when a user hypothesis requires timing or zone-change verification. +Sprint 12.3a executes Judge-tool requests through an autonomous but bounded investigation loop. The planner first decomposes the turn into hypotheses, assigns explicit Oracle or rules evidence requirements, and runs the exact lookup plan. If a hypothesis remains unresolved, it may issue one narrower `rules_search` or `oracle_search` fallback while preserving the same shared budget. -The planner records its goals and request list in `investigation_plan`. Missing or unavailable evidence remains explicit and reduces `judge_verified` rather than being silently guessed. +`investigation_plan` records the initial goals, requests, and hypotheses. `investigation_trace` records every executed phase, the hypotheses affected, evidence counts, sufficiency before and after the call, cache state, errors, and budget snapshots. Missing or unavailable evidence remains explicit and reduces `judge_verified` rather than being silently guessed. + + +## Sprint 12.3a1 land-type layer hardening + +Rules questions that combine land types, mana abilities, layers, dependency, and +timestamp now reserve a complete eight-rule evidence package before incidental +Oracle-derived queries can consume the bounded rules context. The Tactician +planner mirrors the same evidence requirements in its hypotheses. + +A generic deterministic Judge renderer recognizes three reusable Oracle shapes: + +- a global effect that sets nonbasic lands to a basic land type; +- a controller-scoped effect that adds every basic land type; +- a static ability on a nonbasic land that adds one basic land type globally. + +The renderer applies dependency when the subtype-setting effect removes the +nonbasic land source's printed ability, then applies timestamp order between +independent layer-4 effects. It does not branch on exact card names. + +## Sprint 12.3a2 metadata consistency + +The deterministic renderer remains a bounded rules engine rather than a card-name +lookup table. Its contract validator now checks reusable game concepts such as +layer number, basic-land-type outcomes, mana outcomes, dependency, and changed +timestamp order. It no longer depends on one exact pluralization or sentence. + +The Oracle-shape parser also accepts both `a` and `an` before a basic land type. +A regression using fictional names, a Swamp setter, and an Island global adder +ensures that the branch is selected from recovered Oracle text and rules evidence. diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index ac636e0..354acd3 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -1,73 +1,73 @@ -# MagicAI quick start - -## Stable branch - -```bash -git clone https://github.com/Fartis/MagicAI.git -cd MagicAI -``` - -## Active development branch - -```bash -git clone -b develop https://github.com/Fartis/MagicAI.git -cd MagicAI -``` - -## Python environment - -Install `uv`: - -```bash -curl -LsSf https://astral.sh/uv/install.sh | sh -source "$HOME/.local/bin/env" - -## Local sources - -```bash -./scripts/download_sources.sh -./scripts/download_rules.sh -python scripts/update_scryfall_symbology.py -``` - -## Ollama on the same machine - -```bash -ollama pull qwen3:8b -export OLLAMA_URL=http://127.0.0.1:11434/api/chat -``` - -## Ollama in an existing container - -```bash -docker exec ollama ollama pull qwen3:8b -export OLLAMA_URL=http://127.0.0.1:11434/api/chat -``` - -## Ollama on another LAN machine - -```bash -export OLLAMA_URL=http://192.168.1.50:11434/api/chat -``` - -Use only a trusted local network. Do not expose an unprotected Ollama endpoint to the public Internet. - -## Start MagicAI - -```bash -python -m uvicorn magicai.api:app --reload -``` - -Open: - -```text -http://127.0.0.1:8000/ui -``` - -## Smoke test - -```bash -curl -X POST http://127.0.0.1:8000/ask \ - -H 'Content-Type: application/json' \ - -d '{"question":"What happens if I sacrifice Young Wolf?"}' -``` +# MagicAI quick start + +## Stable branch + +```bash +git clone https://github.com/Fartis/MagicAI.git +cd MagicAI +``` + +## Active development branch + +```bash +git clone -b develop https://github.com/Fartis/MagicAI.git +cd MagicAI +``` + +## Python environment + +Install `uv`: + +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +source "$HOME/.local/bin/env" + +## Local sources + +```bash +./scripts/download_sources.sh +./scripts/download_rules.sh +python scripts/update_scryfall_symbology.py +``` + +## Ollama on the same machine + +```bash +ollama pull qwen3:8b +export OLLAMA_URL=http://127.0.0.1:11434/api/chat +``` + +## Ollama in an existing container + +```bash +docker exec ollama ollama pull qwen3:8b +export OLLAMA_URL=http://127.0.0.1:11434/api/chat +``` + +## Ollama on another LAN machine + +```bash +export OLLAMA_URL=http://192.168.1.50:11434/api/chat +``` + +Use only a trusted local network. Do not expose an unprotected Ollama endpoint to the public Internet. + +## Start MagicAI + +```bash +python -m uvicorn magicai.api:app --reload +``` + +Open: + +```text +http://127.0.0.1:8000/ui +``` + +## Smoke test + +```bash +curl -X POST http://127.0.0.1:8000/ask \ + -H 'Content-Type: application/json' \ + -d '{"question":"What happens if I sacrifice Young Wolf?"}' +``` diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md index 1fe7632..3968466 100644 --- a/docs/RELEASE_PROCESS.md +++ b/docs/RELEASE_PROCESS.md @@ -1,75 +1,75 @@ -# Release process - -## Canonical release identity - -MagicAI separates its public release label from Python package metadata: - -```text -Public version: 0.1.1-beta -Git tag: v0.1.1-beta -Package version: 0.1.1b0 -Codename: Force of Will -``` - -Python package versions follow PEP 440. Public tags use lowercase prerelease identifiers consistently. - -## Pre-release checklist - -1. Confirm the release identity in `magicai/versioning.py` and `pyproject.toml`. -2. Update `README.md`, `docs/STATUS.md`, and `docs/ROADMAP.md`. -3. Run the focused CI suite: - - ```bash - python scripts/ci_check.py - ``` - -4. Run the release-specific regression and quality campaigns. -5. Confirm the working tree contains no databases, logs, backups, reports, large downloaded sources, or private files. -6. Build clean source and full packages from tracked files: - - ```bash - python scripts/package_release.py --source - python scripts/package_release.py --full - ``` - -7. Verify the generated SHA-256 files. -8. Open and merge the release pull request from `develop` to `main`. -9. Create the exact lowercase tag declared in `magicai/versioning.py`. -10. Publish release notes that include known limitations and validation performed. - -## Package types - -### Source package - -Contains tracked repository files required for development and installation. It excludes downloaded Scryfall bulk data and local runtime artifacts. - -### Full package - -Contains the same clean tracked repository snapshot and additionally includes the local Oracle and rulings bulk files when present. - -Both packages include a generated `INFO.txt` and `PACKAGE_MANIFEST.json`. - -## Tag policy - -Use consistent lowercase identifiers: - -```text -v0.1.0-alpha -v0.1.1-beta -v0.2.0-beta -v1.0.0 -``` - -Do not create variants such as `Alpha`, `Beta`, or differently formatted tags for the same release. - -## Release notes - -Release notes should describe: - -- user-visible changes; -- authority or source-boundary changes; -- migration steps; -- tests and campaigns executed; -- known limitations; -- package checksums; -- the release codename. +# Release process + +## Canonical release identity + +MagicAI separates its public release label from Python package metadata: + +```text +Public version: 0.1.1-beta +Git tag: v0.1.1-beta +Package version: 0.1.1b0 +Codename: Force of Will +``` + +Python package versions follow PEP 440. Public tags use lowercase prerelease identifiers consistently. + +## Pre-release checklist + +1. Confirm the release identity in `magicai/versioning.py` and `pyproject.toml`. +2. Update `README.md`, `docs/STATUS.md`, and `docs/ROADMAP.md`. +3. Run the focused CI suite: + + ```bash + python scripts/ci_check.py + ``` + +4. Run the release-specific regression and quality campaigns. +5. Confirm the working tree contains no databases, logs, backups, reports, large downloaded sources, or private files. +6. Build clean source and full packages from tracked files: + + ```bash + python scripts/package_release.py --source + python scripts/package_release.py --full + ``` + +7. Verify the generated SHA-256 files. +8. Open and merge the release pull request from `develop` to `main`. +9. Create the exact lowercase tag declared in `magicai/versioning.py`. +10. Publish release notes that include known limitations and validation performed. + +## Package types + +### Source package + +Contains tracked repository files required for development and installation. It excludes downloaded Scryfall bulk data and local runtime artifacts. + +### Full package + +Contains the same clean tracked repository snapshot and additionally includes the local Oracle and rulings bulk files when present. + +Both packages include a generated `INFO.txt` and `PACKAGE_MANIFEST.json`. + +## Tag policy + +Use consistent lowercase identifiers: + +```text +v0.1.0-alpha +v0.1.1-beta +v0.2.0-beta +v1.0.0 +``` + +Do not create variants such as `Alpha`, `Beta`, or differently formatted tags for the same release. + +## Release notes + +Release notes should describe: + +- user-visible changes; +- authority or source-boundary changes; +- migration steps; +- tests and campaigns executed; +- known limitations; +- package checksums; +- the release codename. diff --git a/docs/REPOSITORY_HEALTH.md b/docs/REPOSITORY_HEALTH.md index abfa7c1..0ccd573 100644 --- a/docs/REPOSITORY_HEALTH.md +++ b/docs/REPOSITORY_HEALTH.md @@ -1,51 +1,51 @@ -# Repository health and community readiness - -MagicAI is intended to remain maintainable even when development is no longer concentrated in one person's hands. Repository health therefore advances alongside application features. - -## Current priorities - -### Critical foundation - -- fast GitHub Actions checks for pull requests; -- explicit `main` and `develop` responsibilities; -- clean and reproducible release packages; -- consistent version and tag naming; -- pull request, security, support, and conduct policies; -- automated dependency update proposals; -- removal of generated local artifacts from source exports. - -### Maintainability - -- test categories for unit, integration, quality, slow, Ollama, and network-dependent checks; -- centralized tooling configuration; -- gradual static analysis and typing improvements; -- documented source-authority contracts; -- documented development and release procedures; -- cleanup of merged sprint and temporary backup branches. - -### Community continuity - -- maintainership and governance documents; -- architecture decision records; -- component ownership when multiple maintainers exist; -- newcomer-friendly issues and contribution paths; -- release procedures that another maintainer can execute safely. - -## CI philosophy - -Pull request CI must be fast, local-source independent, and deterministic. Large Oracle campaigns, network integrations, and Ollama-backed evaluations remain separate manual or scheduled workflows. - -A passing CI result means the selected contracts and regressions passed. It does not claim complete Magic rules coverage. - -## Community ownership - -Contributors should be able to determine: - -- where factual authority resides; -- how the Strategist accesses sources; -- how to add a Judge tool; -- how to convert a failure into a regression; -- how to package and release the application; -- how to disclose security concerns safely. - -The project should preserve these decisions in documentation and tests rather than relying on maintainer memory. +# Repository health and community readiness + +MagicAI is intended to remain maintainable even when development is no longer concentrated in one person's hands. Repository health therefore advances alongside application features. + +## Current priorities + +### Critical foundation + +- fast GitHub Actions checks for pull requests; +- explicit `main` and `develop` responsibilities; +- clean and reproducible release packages; +- consistent version and tag naming; +- pull request, security, support, and conduct policies; +- automated dependency update proposals; +- removal of generated local artifacts from source exports. + +### Maintainability + +- test categories for unit, integration, quality, slow, Ollama, and network-dependent checks; +- centralized tooling configuration; +- gradual static analysis and typing improvements; +- documented source-authority contracts; +- documented development and release procedures; +- cleanup of merged sprint and temporary backup branches. + +### Community continuity + +- maintainership and governance documents; +- architecture decision records; +- component ownership when multiple maintainers exist; +- newcomer-friendly issues and contribution paths; +- release procedures that another maintainer can execute safely. + +## CI philosophy + +Pull request CI must be fast, local-source independent, and deterministic. Large Oracle campaigns, network integrations, and Ollama-backed evaluations remain separate manual or scheduled workflows. + +A passing CI result means the selected contracts and regressions passed. It does not claim complete Magic rules coverage. + +## Community ownership + +Contributors should be able to determine: + +- where factual authority resides; +- how the Strategist accesses sources; +- how to add a Judge tool; +- how to convert a failure into a regression; +- how to package and release the application; +- how to disclose security concerns safely. + +The project should preserve these decisions in documentation and tests rather than relying on maintainer memory. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d6e3c2e..fd6649a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -98,14 +98,45 @@ The first major release should add mature deck analysis, authorized strategic so - Young Wolf, Carrion Feeder, and The Ozolith reasoning regression. - Ghave and Ashnod's Altar sequencing, requirements, and disruption regression. -### 12.3 — General autonomous investigation planner — next - -- Hypothesis decomposition. -- Multiple Judge queries per user request. -- Evidence sufficiency scoring. -- Alternative and counterexample search. -- Bounded time and query budgets. -- Full investigation trace. +### 12.3 — General autonomous investigation planner — in progress + +#### 12.3a — Hypothesis and evidence loop — complete + +- Structured hypothesis decomposition from claims, intents, concepts, and active cards. +- Explicit evidence requirements per hypothesis. +- Per-hypothesis and overall evidence-sufficiency scoring. +- Reactive `rules_search` and `oracle_search` fallback for unresolved evidence. +- Alternative or counterexample search selected from the user's speech act. +- Bounded time, total-query, per-tool, and repeated-request budgets. +- Full reusable investigation trace with phases, score changes, errors, cache state, and budget snapshots. +- TacticianResult schema `0.7` and API contract `1.8`. + +#### 12.3a1 — Land-type layer investigation hardening — complete + +- Rules-first classification for questions about layers, dependencies, timestamps, land types, and mana abilities. +- Reserved eight-rule evidence package for basic/nonbasic land type interactions. +- Generic deterministic renderer for subtype-setting and subtype-adding static effects. +- Dependency handling when a nonbasic land source loses its printed static ability. +- Timestamp comparison for independent layer-4 effects. +- Contract coverage for controlled basics, controlled nonbasics, opposing nonbasics, mana colors, and reversed entry order. +- `judge_verified` can no longer be true when the underlying Judge result is `insufficient_evidence`. +- Regression derived from the Blood Moon / Urborg / Dryad manual test without a card-name-specific exception. + +#### 12.3a2 — Deterministic metadata consistency — complete + +- Answer-contract checks align with the generic renderer vocabulary instead of exact sentence fragments. +- Singular and plural basic land types and concrete mana outcomes are recognized in Spanish and English. +- Successful deterministic Judge answers now propagate `answer_complete`, `judge_verified`, high confidence, and the evidence-verification authority trace consistently. +- Oracle pattern matching supports grammatically correct `a` / `an` basic-land-type text. +- A fictional-card regression proves that the renderer is driven by Oracle shapes and rules evidence rather than exact card names. + +#### 12.3b — Generalized adaptive research policies — next + +- Multi-step follow-up policies beyond one fallback per hypothesis. +- Contradiction-aware evidence comparison. +- Dynamic hypothesis expansion from newly recovered evidence. +- Query prioritization by expected information gain and remaining budget. +- Broader regression cases outside the current sacrifice, Undying, counters, and timing families. ### 12.4 — Formal combo verifier diff --git a/docs/STATUS.md b/docs/STATUS.md index cca72cc..1e5dcf5 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -9,36 +9,40 @@ ## Current development line -Sprint `12.2d` adds response orchestration and the first full Tactician Conversation Gauntlet. +Sprint `12.3a2` closes the land-type investigation line with metadata consistency and renderer generalization. Completed in this milestone: -- explicit `judge_led`, `tactician_led`, and `hybrid` response modes; -- rules and mechanic questions led by the Judge factual core; -- strategic questions led by the Tactician; -- mixed rules-and-strategy questions handled through a hybrid path; -- deterministic factual-core extraction and coverage checks; -- protection against replacing a correct Judge answer with a generic combo template; -- combo classification suppressed when the current turn is not asking about a combo; -- `mechanic_resolution` intent for questions such as sacrificing Young Wolf; -- session-aware Spanish and English output preserved across all response fields; -- 40 data-driven conversational scenarios covering 58 turns; -- controlled Judge and Judge Tool Gateway fixtures for fast CI; -- semantic, negative, evidence, language, and continuity assertions; -- JSON and HTML conversation-gauntlet reports; -- a review-only command for promoting exported UI feedback into candidate cases; -- TacticianResult schema `0.6` and API contract `1.7`. +- structured hypothesis decomposition from user claims, strategic intent, concepts, and active cards; +- explicit required-evidence tokens for Oracle cards and Comprehensive Rules sections; +- deterministic sufficiency scores for each hypothesis and the overall investigation; +- reactive fallback searches when exact Oracle or rules lookups leave evidence unresolved; +- alternative-search and counterexample-search phases selected from the user's speech act; +- enforcement of total-call, per-tool, repeated-request, and elapsed-time budgets; +- full reusable `investigation_trace` output with request phases, score changes, errors, cache state, and budget snapshots; +- Oracle evidence refresh folded into the same auditable planner loop; +- casual Spanish validation markers such as `verdad` and `cierto` recognized as requests to challenge a premise; +- pytest restricted to the active `tests/` tree so historical `backups/` do not collide during collection; +- TacticianResult schema `0.7` and API contract `1.8`; +- rules questions involving land types, mana abilities, layers, dependencies, and timestamps are classified as Judge-led; +- a reserved Comprehensive Rules package pins 305.6, 305.7, 305.8, 611.3, 613.1d, 613.7, 613.8a, and 613.8b; +- a generic deterministic renderer resolves a nonbasic-land type setter against additive basic-land-type effects; +- dependency is evaluated before timestamp when applying the setter removes the source ability of a nonbasic land; +- answer obligations now cover each requested land category, resulting mana colors, and reversed entry order; +- an `insufficient_evidence` Judge result cannot be reported as `judge_verified` or `answer_complete`; +- deterministic answers and their Tactician metadata now agree on completeness, confidence, and evidence verification; +- answer-contract checks recognize singular/plural basic land types and general mana outcomes rather than one exact phrase; +- the Oracle-pattern renderer accepts both `a` and `an` before a basic land type and is regression-tested with fictional card names and different land types. ## Next development line -Sprint `12.3` will focus on the general autonomous investigation planner: +Sprint `12.3b` will generalize adaptive research policies: -- hypothesis decomposition; -- iterative evidence requests based on unresolved claims; -- evidence-sufficiency scoring; -- alternative and counterexample search; -- bounded research loops; -- reusable investigation traces beyond the currently supported interaction families. +- multi-step follow-up policies beyond one fallback per hypothesis; +- contradiction-aware evidence comparison; +- dynamic hypothesis expansion from newly recovered evidence; +- query prioritization by expected information gain and remaining budget; +- broader regression cases outside the current deterministic interaction families. ## Known limitations diff --git a/docs/TACTICIAN.md b/docs/TACTICIAN.md index 3d5f0a8..658dc98 100644 --- a/docs/TACTICIAN.md +++ b/docs/TACTICIAN.md @@ -68,7 +68,7 @@ Tactician forms a hypothesis → publish or declare uncertainty ``` -Milestone 0.6 adds response ownership and a reproducible conversation gauntlet. Sprint 12.3 generalizes the investigation loop beyond the current deterministic families. +Milestone 0.7 adds explicit hypotheses, sufficiency scoring, reactive fallback searches, bounded execution, and a reusable investigation trace. Sprint 12.3b will generalize the loop beyond the current deterministic families. ## Personality diff --git a/docs/TACTICIAN_REASONING.md b/docs/TACTICIAN_REASONING.md index 751c94b..ab50440 100644 --- a/docs/TACTICIAN_REASONING.md +++ b/docs/TACTICIAN_REASONING.md @@ -1,6 +1,6 @@ # Tactician input reasoning and response orchestration -Sprint 12.2d extends the structured reasoning layer with explicit response ownership, factual-core preservation, and a reproducible multi-turn conversation gauntlet. +Sprint 12.3a extends the structured reasoning layer with explicit hypotheses, evidence-sufficiency scoring, bounded reactive searches, response ownership, and factual-core preservation. ## Goal @@ -14,7 +14,9 @@ user wording → session language policy → speech-act and intent analysis → claim and question-target extraction + → hypothesis decomposition and evidence requirements → bounded Judge Tool Gateway investigation + → sufficiency scoring and reactive fallback search → claim verdicts → response-mode arbitration → factual-core extraction @@ -143,6 +145,37 @@ python scripts/promote_tactician_feedback.py exported-result.json The command never modifies the active regression corpus automatically. A human must add semantic and negative expectations before promotion. +## Autonomous investigation trace + +Sprint 12.3a exposes a structured, non-chain-of-thought audit record. Each hypothesis lists its required evidence, resolved and missing evidence, sufficiency score, and whether a fallback search was attempted. Each step records its phase, request, affected hypotheses, score change, result status, and budget state. + +The trace is intended for diagnostics, regression tests, and UI inspection. It does not expose private model reasoning. + ## Limits -This milestone is not a universal natural-language theorem prover. Factual-core extraction and semantic checks remain deterministic and intentionally bounded. Sprint 12.3 will generalize autonomous investigation planning and evidence sufficiency across broader interactions. +This milestone is not a universal natural-language theorem prover. Hypothesis templates and follow-up policies remain deterministic and intentionally bounded. Sprint 12.3b will add contradiction-aware comparison, dynamic hypothesis expansion, and broader interaction families. + + +## Land-type layer regression + +The manual Blood Moon, Urborg, and Dryad test exposed a false-positive +sufficiency state: Oracle verification alone was scored as complete even though +the user explicitly asked for layer, dependency, timestamp, land-type, and mana +conclusions. Sprint 12.3a1 corrects this by deriving those concepts from the +question and attaching the complete rules package to the answer-basis +hypothesis. + +Investigation sufficiency and Judge verification are deliberately separate. +A complete set of lookup results does not make an incomplete Judge answer +verified. `judge_verified` now also requires a non-insufficient Judge status, +and Judge-led answers need either a preserved factual core or a recognized +source-grounded semantic fallback. + +## Deterministic answer metadata + +Sprint 12.3a2 aligns the public metadata with the actual Judge result. When a +source-grounded deterministic answer satisfies every obligation and the planned +evidence is complete, the Tactician reports the answer as complete and verified, +propagates high confidence, and records `judge:evidence_verification`. An +insufficient Judge result continues to fail closed even if the lookup plan itself +found every requested source. diff --git a/magicai/api/health.py b/magicai/api/health.py index acf154e..8249cf1 100644 --- a/magicai/api/health.py +++ b/magicai/api/health.py @@ -1,121 +1,121 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import Any -from urllib.parse import urlsplit, urlunsplit - -import requests - -from magicai.llm.ollama import MODEL, OLLAMA_URL -from magicai.sources.health import SourceHealth, get_source_health -from magicai.versioning import ( - API_CONTRACT_VERSION, - JUDGE_RESULT_SCHEMA_VERSION, - get_project_version, - get_package_version, - RELEASE_CHANNEL, - RELEASE_CODENAME, - RELEASE_TAG, -) - - -@dataclass(frozen=True, slots=True) -class ServiceProbe: - status: str - available: bool - detail: str = "" - model: str | None = None - - def to_dict(self) -> dict[str, Any]: - return { - "status": self.status, - "available": self.available, - "detail": self.detail, - "model": self.model, - } - - -def probe_ollama(timeout: float = 2.0) -> ServiceProbe: - tags_url = _ollama_tags_url(OLLAMA_URL) - - try: - response = requests.get(tags_url, timeout=timeout) - response.raise_for_status() - payload = response.json() - except (requests.RequestException, ValueError) as error: - return ServiceProbe( - status="unavailable", - available=False, - detail=f"Ollama did not respond successfully: {error.__class__.__name__}.", - model=MODEL, - ) - - models = { - str(item.get("name", "")) - for item in payload.get("models", []) - if isinstance(item, dict) - } - model_available = ( - MODEL in models - or any(name.split(":", 1)[0] == MODEL.split(":", 1)[0] for name in models) - ) - - return ServiceProbe( - status="available" if model_available else "degraded", - available=model_available, - detail=( - "Ollama and the configured model are available." - if model_available - else "Ollama is reachable, but the configured model was not listed." - ), - model=MODEL, - ) - - -def build_health_payload( - *, - source_health: SourceHealth | None = None, - ollama_probe: ServiceProbe | None = None, -) -> dict[str, Any]: - sources = source_health or get_source_health() - ollama = ollama_probe or probe_ollama() - - ready = sources.ready - full_service = ready and ollama.available - - if not ready: - status = "unavailable" - elif full_service and sources.complete: - status = "ok" - else: - status = "degraded" - - return { - "status": status, - "ready": ready, - "full_service": full_service, - "project_version": get_project_version(), - "package_version": get_package_version(), - "release_channel": RELEASE_CHANNEL, - "release_codename": RELEASE_CODENAME, - "release_tag": RELEASE_TAG, - "api_contract_version": API_CONTRACT_VERSION, - "judge_result_schema_version": JUDGE_RESULT_SCHEMA_VERSION, - "sources": sources.to_dict(), - "services": { - "ollama": ollama.to_dict(), - }, - } - - -def _ollama_tags_url(chat_url: str) -> str: - parsed = urlsplit(chat_url) - return urlunsplit( - ( - parsed.scheme, - parsed.netloc, - "/api/tags", - "", - "", - ) - ) +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any +from urllib.parse import urlsplit, urlunsplit + +import requests + +from magicai.llm.ollama import MODEL, OLLAMA_URL +from magicai.sources.health import SourceHealth, get_source_health +from magicai.versioning import ( + API_CONTRACT_VERSION, + JUDGE_RESULT_SCHEMA_VERSION, + get_project_version, + get_package_version, + RELEASE_CHANNEL, + RELEASE_CODENAME, + RELEASE_TAG, +) + + +@dataclass(frozen=True, slots=True) +class ServiceProbe: + status: str + available: bool + detail: str = "" + model: str | None = None + + def to_dict(self) -> dict[str, Any]: + return { + "status": self.status, + "available": self.available, + "detail": self.detail, + "model": self.model, + } + + +def probe_ollama(timeout: float = 2.0) -> ServiceProbe: + tags_url = _ollama_tags_url(OLLAMA_URL) + + try: + response = requests.get(tags_url, timeout=timeout) + response.raise_for_status() + payload = response.json() + except (requests.RequestException, ValueError) as error: + return ServiceProbe( + status="unavailable", + available=False, + detail=f"Ollama did not respond successfully: {error.__class__.__name__}.", + model=MODEL, + ) + + models = { + str(item.get("name", "")) + for item in payload.get("models", []) + if isinstance(item, dict) + } + model_available = ( + MODEL in models + or any(name.split(":", 1)[0] == MODEL.split(":", 1)[0] for name in models) + ) + + return ServiceProbe( + status="available" if model_available else "degraded", + available=model_available, + detail=( + "Ollama and the configured model are available." + if model_available + else "Ollama is reachable, but the configured model was not listed." + ), + model=MODEL, + ) + + +def build_health_payload( + *, + source_health: SourceHealth | None = None, + ollama_probe: ServiceProbe | None = None, +) -> dict[str, Any]: + sources = source_health or get_source_health() + ollama = ollama_probe or probe_ollama() + + ready = sources.ready + full_service = ready and ollama.available + + if not ready: + status = "unavailable" + elif full_service and sources.complete: + status = "ok" + else: + status = "degraded" + + return { + "status": status, + "ready": ready, + "full_service": full_service, + "project_version": get_project_version(), + "package_version": get_package_version(), + "release_channel": RELEASE_CHANNEL, + "release_codename": RELEASE_CODENAME, + "release_tag": RELEASE_TAG, + "api_contract_version": API_CONTRACT_VERSION, + "judge_result_schema_version": JUDGE_RESULT_SCHEMA_VERSION, + "sources": sources.to_dict(), + "services": { + "ollama": ollama.to_dict(), + }, + } + + +def _ollama_tags_url(chat_url: str) -> str: + parsed = urlsplit(chat_url) + return urlunsplit( + ( + parsed.scheme, + parsed.netloc, + "/api/tags", + "", + "", + ) + ) diff --git a/magicai/api/schemas.py b/magicai/api/schemas.py index f32ae72..10c5d2b 100644 --- a/magicai/api/schemas.py +++ b/magicai/api/schemas.py @@ -77,6 +77,7 @@ class AskResponse(BaseModel): queries_completed: int = 0 judge_verified: bool = False investigation_plan: dict[str, Any] = Field(default_factory=dict) + investigation_trace: dict[str, Any] = Field(default_factory=dict) response_language: str = "" language_policy: dict[str, Any] = Field(default_factory=dict) answer_obligations: list[dict[str, Any]] = Field(default_factory=list) diff --git a/magicai/retrieval/concept_evidence.py b/magicai/retrieval/concept_evidence.py index d0562eb..1a974f9 100644 --- a/magicai/retrieval/concept_evidence.py +++ b/magicai/retrieval/concept_evidence.py @@ -10,6 +10,19 @@ # An activated or triggered ability on the stack is independent from its # source, but resolution can still need source information or the object. "source_independence": ("113.7a", "405.1", "608.2h", "609.3"), + # Land-type-setting interactions need a complete, reserved layer package. + # This prevents incidental Oracle keywords from displacing timestamp, + # dependency, basic-supertype, and intrinsic-mana evidence. + "basic_land_layer_interaction": ( + "305.6", + "305.7", + "305.8", + "611.3", + "613.1d", + "613.7", + "613.8a", + "613.8b", + ), } @@ -20,6 +33,8 @@ def detected_evidence_concepts(question: str) -> tuple[str, ...]: concepts.append("ward") if _looks_like_source_independence(q): concepts.append("source_independence") + if _looks_like_basic_land_layer_interaction(q): + concepts.append("basic_land_layer_interaction") return tuple(concepts) @@ -32,6 +47,64 @@ def mandatory_rule_numbers(question: str) -> tuple[str, ...]: return tuple(numbers) + +def _looks_like_basic_land_layer_interaction(question: str) -> bool: + has_land_types = any( + marker in question + for marker in ( + "tipo de tierra", + "tipos de tierra", + "tierra basica", + "tierra básica", + "tierras basicas", + "tierras básicas", + "tierra no basica", + "tierra no básica", + "tierras no basicas", + "tierras no básicas", + "land type", + "land types", + "basic land", + "nonbasic land", + ) + ) + has_ordering = any( + marker in question + for marker in ( + "capa", + "capas", + "layer", + "layers", + "dependencia", + "dependency", + "timestamp", + "marca de tiempo", + "orden de entrada", + "antes que", + "despues que", + "después que", + ) + ) + has_mana_or_effect = any( + marker in question + for marker in ( + "habilidad de mana", + "habilidad de maná", + "habilidades de mana", + "habilidades de maná", + "pueden producir", + "produce mana", + "produce maná", + "mana ability", + "mana abilities", + "in addition to their other types", + "ademas de sus otros tipos", + "además de sus otros tipos", + ) + ) + return has_land_types and has_ordering and has_mana_or_effect + + def _looks_like_source_independence(question: str) -> bool: has_ability = "habilidad" in question or "ability" in question has_stack_or_resolution = any( diff --git a/magicai/retrieval/rule_queries.py b/magicai/retrieval/rule_queries.py index c5e2b01..8c915c9 100644 --- a/magicai/retrieval/rule_queries.py +++ b/magicai/retrieval/rule_queries.py @@ -450,17 +450,25 @@ def add(*numbers: str): # necesitan capas antes que reglas incidentales de lanzamiento o Commander. if _is_characteristic_continuous_effect_question(q): + basic_land_interaction = _mentions_basic_land_type_interaction(q) add( "611.3", "613.1d", "613.1f", - "613.4b", "613.6", "613.8", ) - if _mentions_basic_land_type_interaction(q): - add("305.7") + add("613.4b") + if basic_land_interaction: + add( + "305.6", + "305.7", + "305.8", + "613.7", + "613.8a", + "613.8b", + ) if _mentions_mana_value(q): add("202.3") @@ -875,8 +883,15 @@ def _specialized_queries(question: str) -> list[str]: ) if _mentions_basic_land_type_interaction(q): - queries.append( - "basic land type loses abilities generated from rules text 305.7" + queries.extend( + [ + "basic land types intrinsic mana abilities 305.6", + "basic land type loses abilities generated from rules text 305.7", + "basic supertype versus basic land type 305.8", + "timestamp order continuous effects 613.7", + "dependency changes existence of effect 613.8a", + "dependent effect waits until other effect applied 613.8b", + ] ) if _contains_any( @@ -1401,6 +1416,21 @@ def _mentions_basic_land_type_interaction(question: str) -> bool: "basic land type", "tipo de tierra básica", "tipo de tierra basica", + "tipos de tierra", + "tipo de tierra", + "tierra no básica", + "tierra no basica", + "tierras no básicas", + "tierras no basicas", + "land type", + "land types", + "nonbasic land", + "nonbasic lands", + "habilidad de maná", + "habilidad de mana", + "habilidades de maná", + "habilidades de mana", + "pueden producir", "plains", "island", "swamp", diff --git a/magicai/tactician/answer_contract.py b/magicai/tactician/answer_contract.py index 9e3415f..80d9529 100644 --- a/magicai/tactician/answer_contract.py +++ b/magicai/tactician/answer_contract.py @@ -72,6 +72,37 @@ def build_answer_obligations(analysis: InputAnalysis, *, has_current_interaction "apply_current_interaction", "Aplicar la regla a la interacción activa." if spanish else "Apply the rule to the active interaction.", )) + elif ( + intent is StrategyIntent.RULES_CLARIFICATION + and {"land_types", "basic_lands", "nonbasic_lands", "mana_abilities"} & set(analysis.concepts) + and {"layers", "dependency", "timestamp"} & set(analysis.concepts) + ): + obligations.extend([ + AnswerObligation( + "land_type_layer", + "Explicar que los efectos se aplican en la capa 4." if spanish else "Explain that the effects apply in layer 4.", + ), + AnswerObligation( + "dependency_before_timestamp", + "Explicar cuándo la dependencia prevalece sobre la marca de tiempo." if spanish else "Explain when dependency overrides timestamp.", + ), + AnswerObligation( + "basic_land_result", + "Resolver las tierras básicas del jugador." if spanish else "Resolve the player's basic lands.", + ), + AnswerObligation( + "controlled_nonbasic_result", + "Resolver las tierras no básicas del jugador." if spanish else "Resolve the player's nonbasic lands.", + ), + AnswerObligation( + "opponent_nonbasic_result", + "Resolver las tierras no básicas del oponente." if spanish else "Resolve the opponent's nonbasic lands.", + ), + AnswerObligation( + "timestamp_order_comparison", + "Comparar el resultado cuando cambia el orden de entrada." if spanish else "Compare the result when the entry order changes.", + ), + ]) elif intent is StrategyIntent.COMBO_FAILURE_EXPLANATION: obligations.extend([ AnswerObligation("identify_failed_transition", "Identificar el paso exacto que impide el bucle." if spanish else "Identify the exact transition that stops the loop."), @@ -162,6 +193,56 @@ def _check_obligation( marker in answer for marker in ("ozolith", "young wolf", "carrion feeder", "ghave", "ashnod") ) + if code == "land_type_layer": + # This obligation is only created for a land-type interaction, so the + # response only needs to identify the correct layer. Requiring one + # exact wording such as "tipo de tierra" made valid deterministic + # answers fail their own contract. + return any(marker in answer for marker in ("capa 4", "layer 4")) + if code == "dependency_before_timestamp": + has_dependency = any(marker in answer for marker in ("dependencia", "dependency")) + has_timestamp = any(marker in answer for marker in ("timestamp", "marca de tiempo")) + has_priority = any(marker in answer for marker in ("prevalece", "anula", "antes que", "override", "overrides")) + return has_dependency and has_timestamp and has_priority + if code == "basic_land_result": + has_scope = any(marker in answer for marker in ("tierras basicas", "tierras básicas", "basic lands")) + has_result = any(marker in answer for marker in ("cinco colores", "wubrg", "{w}", "todos los tipos basicos", "todos los tipos básicos", "every basic land type", "all five")) + return has_scope and has_result + if code == "controlled_nonbasic_result": + has_scope = any(marker in answer for marker in ( + "tus tierras no basicas", + "your nonbasic lands", + )) + return has_scope and _has_land_or_mana_result(answer) + if code == "opponent_nonbasic_result": + has_scope = any(marker in answer for marker in ( + "tierras no basicas del oponente", + "opponent's nonbasic lands", + "opponent nonbasic lands", + )) + return has_scope and _has_land_or_mana_result(answer) + if code == "timestamp_order_comparison": + has_order = any(marker in answer for marker in ( + "hubiera entrado antes", + "entrara antes", + "entra antes", + "orden alternativo", + "orden se invierte", + "if it entered before", + "entered before", + "if the order is reversed", + "alternative order", + )) + has_consequence = any(marker in answer for marker in ( + "terminarian", + "terminaria", + "podrian producir", + "podria producir", + "would end", + "could produce", + "can produce", + )) + return has_order and has_consequence and _has_land_or_mana_result(answer) if code == "identify_failed_transition": return any(marker in answer for marker in ("no se dispara", "no vuelve", "se rompe", "does not trigger", "does not return", "breaks")) if code == "explain_rule": @@ -178,6 +259,59 @@ def _check_obligation( return False +def _has_land_or_mana_result(answer: str) -> bool: + """Recognize a concrete land-type or mana outcome without card names. + + These are game vocabulary markers, not interaction-specific phrases. The + contract therefore works for any Oracle cards matching the same rules + family and for singular or plural basic-land type wording. + """ + + land_types = ( + "llanura", + "llanuras", + "isla", + "islas", + "pantano", + "pantanos", + "montana", + "montanas", + "bosque", + "bosques", + "plain", + "plains", + "island", + "islands", + "swamp", + "swamps", + "mountain", + "mountains", + "forest", + "forests", + ) + mana_results = ( + "cinco colores", + "all five", + "w, u, b, r o g", + "w, u, b, r, or g", + "mana blanco", + "mana azul", + "mana negro", + "mana rojo", + "mana verde", + "white mana", + "blue mana", + "black mana", + "red mana", + "green mana", + "producir w", + "produce w", + ) + return any(marker in answer for marker in land_types) and any( + marker in answer for marker in mana_results + ) + + def _wrong_language(answer: str, *, expected: str) -> bool: normalized = _normalize(answer) if expected == "es": diff --git a/magicai/tactician/core.py b/magicai/tactician/core.py index fb3f14d..69f6760 100644 --- a/magicai/tactician/core.py +++ b/magicai/tactician/core.py @@ -8,7 +8,6 @@ JudgeToolBudget, JudgeToolGateway, JudgeToolRequest, - JudgeToolStatus, ) from magicai.language.localization import localize_messages from magicai.tactician.answer_contract import ( @@ -25,6 +24,7 @@ preserve_factual_core, ) from magicai.tactician.input_analysis import analyze_user_input +from magicai.tactician.investigation import InvestigationOutcome, run_investigation from magicai.tactician.intents import looks_like_referential_follow_up from magicai.tactician.models import TacticianResult from magicai.tactician.orchestration import ( @@ -115,28 +115,29 @@ def from_judge_result( max_repeated_request=1, max_elapsed_seconds=30.0, ) - tool_calls: list[dict[str, Any]] = [] - if conversation is not None and merged_cards: - merged_cards, oracle_calls = self._refresh_oracle_evidence( - cards=merged_cards, - conversation=conversation, - budget=budget, - ) - tool_calls.extend(oracle_calls) - plan = plan_investigation( input_analysis, cards=merged_cards, - oracle_already_refreshed=bool(tool_calls), + oracle_already_refreshed=False, ) - if conversation is not None and plan.requests: - for request in plan.requests: - result = self.execute_judge_tool( - request, - conversation=conversation, - budget=budget, - ) - tool_calls.append(result.to_dict()) + plan_payload = plan.to_dict() + if conversation is not None: + investigation = run_investigation( + analysis=input_analysis, + requests=plan.requests, + hypotheses=plan.hypotheses, + execute=self.execute_judge_tool, + conversation=conversation, + budget=budget, + ) + else: + investigation = InvestigationOutcome( + hypotheses=plan.hypotheses, + stopped_reason="tools_not_executed", + initial_queries=len(plan.requests), + ) + tool_calls = investigation.tool_calls + merged_cards = _merge_cards_from_tool_calls(merged_cards, tool_calls) judge_payload = _merge_tool_evidence( {**judge_payload, "cards": merged_cards}, @@ -193,7 +194,20 @@ def from_judge_result( obligations=obligations, has_current_interaction=has_current_interaction, ) - answer_complete = answer_contract.complete and factual_coverage.complete + semantic_fallback_available = ( + investigation.sufficient + and bool(synthesis.reasoning_summary) + ) + factual_basis_available = ( + response_decision.mode is not ResponseMode.JUDGE_LED + or bool(factual_core) + or semantic_fallback_available + ) + answer_complete = ( + answer_contract.complete + and factual_coverage.complete + and factual_basis_available + ) judge_status = str(judge_payload.get("status", "")) if judge_status not in {"strategy_required", "answered"}: @@ -218,6 +232,8 @@ def from_judge_result( tool_calls=tool_calls, answer_complete=answer_complete, factual_core_preserved=factual_core_preserved, + investigation_sufficient=investigation.sufficient, + judge_status=judge_status, ) confidence = _strategy_confidence( combo_classification, @@ -254,6 +270,7 @@ def from_judge_result( "tactician:language_policy", "tactician:input_analysis", "tactician:claim_evaluation", + "tactician:autonomous_investigation", f"tactician:response_orchestration:{response_decision.mode.value}", "tactician:factual_core_preservation", "tactician:answer_contract", @@ -295,10 +312,11 @@ def from_judge_result( input_analysis=input_analysis.to_dict(), claim_verdicts=[verdict.to_dict() for verdict in claim_verdicts], reasoning_summary=synthesis.reasoning_summary, - queries_planned=len(plan.requests) + (1 if merged_cards else 0), + queries_planned=investigation.initial_queries + investigation.follow_up_queries, queries_completed=len(tool_calls), judge_verified=judge_verified, - investigation_plan=plan.to_dict(), + investigation_plan=plan_payload, + investigation_trace=investigation.to_dict(), response_language=input_analysis.language, language_policy=dict(input_analysis.language_policy), answer_obligations=[item.to_dict() for item in obligations], @@ -331,43 +349,6 @@ def execute_judge_tool( budget=budget, ) - def _refresh_oracle_evidence( - self, - *, - cards: list[dict[str, Any]], - conversation, - budget: JudgeToolBudget, - ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: - names = [str(card.get("name", "")).strip() for card in cards] - names = [name for name in names if name] - if not names: - return cards, [] - - result = self.execute_judge_tool( - JudgeToolRequest( - tool="oracle_lookup", - arguments={"card_names": names}, - purpose="refresh_strategic_oracle_evidence", - result_limit=max(1, min(len(names), 20)), - ), - conversation=conversation, - budget=budget, - ) - payload = result.to_dict() - if result.status is not JudgeToolStatus.SUCCESS: - return cards, [payload] - - refreshed = [ - dict(item.get("data", {})) - for item in result.evidence - if item.get("kind") == "card" and item.get("data") - ] - if not refreshed: - return cards, [payload] - - return _merge_refreshed_cards(cards, refreshed), [payload] - - def replace_boundary_answer(conversation, result: TacticianResult) -> None: """Replace the Judge boundary message after an automatic handoff.""" @@ -420,6 +401,8 @@ def _judge_verified( tool_calls: list[dict[str, Any]], answer_complete: bool, factual_core_preserved: bool, + investigation_sufficient: bool, + judge_status: str, ) -> bool: unresolved = [ verdict @@ -430,7 +413,13 @@ def _judge_verified( call.get("status") == "success" and call.get("evidence") for call in tool_calls ) - if unresolved or not answer_complete or not factual_core_preserved: + if ( + unresolved + or judge_status not in {"answered", "strategy_required", "false_premise"} + or not answer_complete + or not factual_core_preserved + or not investigation_sufficient + ): return False if claim_verdicts: return successful_evidence @@ -474,6 +463,23 @@ def _merge_tool_evidence( return {**payload, "rules": rules, "rulings": rulings} +def _merge_cards_from_tool_calls( + original: list[dict[str, Any]], + tool_calls: list[dict[str, Any]], +) -> list[dict[str, Any]]: + refreshed: list[dict[str, Any]] = [] + for call in tool_calls: + for item in call.get("evidence", []): + if item.get("kind") != "card": + continue + data = item.get("data", {}) + if isinstance(data, dict) and data.get("name"): + refreshed.append(dict(data)) + if not refreshed: + return original + return _merge_refreshed_cards(original, refreshed) + + def _merge_context_cards( *, current: list[Any], diff --git a/magicai/tactician/input_analysis.py b/magicai/tactician/input_analysis.py index bd4afd2..81925bb 100644 --- a/magicai/tactician/input_analysis.py +++ b/magicai/tactician/input_analysis.py @@ -83,7 +83,7 @@ def to_dict(self) -> dict[str, object]: ) _VALIDATION_MARKERS = ( "hace combo", "es correcto", "tengo razon", "tengo razón", "funciona asi", "funciona así", - "seria asi", "sería así", "is that correct", "does that work", + "seria asi", "sería así", "verdad", "cierto", "is that correct", "does that work", ) @@ -218,6 +218,14 @@ def _detect_concepts(normalized: str) -> list[str]: ("disruption", ("cortar", "interrump", "disrupt", "stop")), ("definition", ("que es", "como funciona", "define", "what is", "how does")), ("equivalence", ("es lo mismo", "equivale", "no cuando", "mismo evento", "same event")), + ("continuous_effects", ("efecto continuo", "efectos continuos", "continuous effect", "continuous effects")), + ("layers", ("capa", "capas", "layer", "layers")), + ("dependency", ("dependencia", "dependency")), + ("timestamp", ("timestamp", "marca de tiempo", "orden de entrada", "antes que", "despues que", "después que")), + ("land_types", ("tipo de tierra", "tipos de tierra", "land type", "land types")), + ("basic_lands", ("tierra basica", "tierra básica", "tierras basicas", "tierras básicas", "basic land", "basic lands")), + ("nonbasic_lands", ("tierra no basica", "tierra no básica", "tierras no basicas", "tierras no básicas", "nonbasic land", "nonbasic lands")), + ("mana_abilities", ("habilidad de mana", "habilidad de maná", "habilidades de mana", "habilidades de maná", "mana ability", "mana abilities", "pueden producir")), ) for concept, markers in mappings: if any(marker in normalized for marker in markers): diff --git a/magicai/tactician/intents.py b/magicai/tactician/intents.py index fa580b2..b8148f1 100644 --- a/magicai/tactician/intents.py +++ b/magicai/tactician/intents.py @@ -80,6 +80,10 @@ class StrategyIntent(str, Enum): _RULES_CLARIFICATION_MARKERS = ( "regla", "rules", "se dispara", "se activa", "trigger", "cementerio", "graveyard", "muere", "dies", "prioridad", "priority", "pila", "stack", + "capa", "capas", "layer", "layers", "dependencia", "dependency", + "timestamp", "marca de tiempo", "tipo de tierra", "tipos de tierra", + "land type", "land types", "habilidad de mana", "habilidades de mana", + "mana ability", "mana abilities", ) diff --git a/magicai/tactician/investigation.py b/magicai/tactician/investigation.py new file mode 100644 index 0000000..cc4c215 --- /dev/null +++ b/magicai/tactician/investigation.py @@ -0,0 +1,555 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Callable + +from magicai.judge_tools import ( + JudgeToolBudget, + JudgeToolRequest, + JudgeToolResult, + JudgeToolStatus, +) +from magicai.tactician.input_analysis import ClaimKind, InputAnalysis, SpeechAct +from magicai.tactician.intents import StrategyIntent + + +class HypothesisStatus(str, Enum): + OPEN = "open" + SUFFICIENT = "sufficient" + INSUFFICIENT = "insufficient" + + +@dataclass(slots=True) +class InvestigationHypothesis: + hypothesis_id: str + statement: str + kind: str + concepts: tuple[str, ...] = () + required_evidence: tuple[str, ...] = () + search_queries: tuple[str, ...] = () + status: HypothesisStatus = HypothesisStatus.OPEN + sufficiency_score: float = 0.0 + resolved_evidence: tuple[str, ...] = () + missing_evidence: tuple[str, ...] = () + follow_up_attempted: bool = False + + def evaluate(self, available_evidence: set[str]) -> None: + required = tuple(_normalize_token(item) for item in self.required_evidence if item) + if not required: + self.sufficiency_score = 1.0 + self.status = HypothesisStatus.SUFFICIENT + self.resolved_evidence = () + self.missing_evidence = () + return + + resolved = tuple(item for item in required if item in available_evidence) + missing = tuple(item for item in required if item not in available_evidence) + self.resolved_evidence = resolved + self.missing_evidence = missing + self.sufficiency_score = round(len(resolved) / len(required), 3) + self.status = ( + HypothesisStatus.SUFFICIENT + if not missing + else HypothesisStatus.INSUFFICIENT + ) + + def to_dict(self) -> dict[str, object]: + return { + "id": self.hypothesis_id, + "statement": self.statement, + "kind": self.kind, + "concepts": list(self.concepts), + "required_evidence": list(self.required_evidence), + "search_queries": list(self.search_queries), + "status": self.status.value, + "sufficiency_score": self.sufficiency_score, + "resolved_evidence": list(self.resolved_evidence), + "missing_evidence": list(self.missing_evidence), + "follow_up_attempted": self.follow_up_attempted, + } + + +@dataclass(slots=True) +class InvestigationStep: + sequence: int + phase: str + request: JudgeToolRequest + status: str + evidence_count: int + hypothesis_ids: tuple[str, ...] = () + sufficiency_before: float = 0.0 + sufficiency_after: float = 0.0 + cache_hit: bool = False + error_code: str = "" + budget: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, object]: + return { + "sequence": self.sequence, + "phase": self.phase, + "hypothesis_ids": list(self.hypothesis_ids), + "request": self.request.to_dict(), + "status": self.status, + "evidence_count": self.evidence_count, + "sufficiency_before": self.sufficiency_before, + "sufficiency_after": self.sufficiency_after, + "cache_hit": self.cache_hit, + "error_code": self.error_code, + "budget": dict(self.budget), + } + + +@dataclass(slots=True) +class InvestigationOutcome: + tool_calls: list[dict[str, Any]] = field(default_factory=list) + hypotheses: list[InvestigationHypothesis] = field(default_factory=list) + steps: list[InvestigationStep] = field(default_factory=list) + sufficiency_score: float = 0.0 + sufficient: bool = False + stopped_reason: str = "not_started" + initial_queries: int = 0 + follow_up_queries: int = 0 + + def to_dict(self) -> dict[str, object]: + return { + "sufficient": self.sufficient, + "sufficiency_score": self.sufficiency_score, + "stopped_reason": self.stopped_reason, + "initial_queries": self.initial_queries, + "follow_up_queries": self.follow_up_queries, + "hypotheses": [item.to_dict() for item in self.hypotheses], + "steps": [item.to_dict() for item in self.steps], + } + + +JudgeToolExecutor = Callable[..., JudgeToolResult] + + +def build_hypotheses( + analysis: InputAnalysis, + *, + cards: list[dict[str, Any]], +) -> list[InvestigationHypothesis]: + hypotheses: list[InvestigationHypothesis] = [] + card_names = [str(card.get("name", "")).strip() for card in cards if card.get("name")] + + for claim in analysis.claims: + required, queries = _claim_requirements(claim.kind, claim.concepts, card_names) + hypotheses.append( + InvestigationHypothesis( + hypothesis_id=f"hypothesis-{len(hypotheses) + 1}", + statement=claim.text, + kind=claim.kind.value, + concepts=claim.concepts, + required_evidence=required, + search_queries=queries, + ) + ) + + if card_names and not any( + any(token.startswith("card:") for token in item.required_evidence) + for item in hypotheses + ): + hypotheses.insert( + 0, + InvestigationHypothesis( + hypothesis_id="hypothesis-1", + statement=( + "El análisis usa el texto Oracle actual de todas las cartas implicadas." + if analysis.language == "es" + else "The analysis uses current Oracle text for every referenced card." + ), + kind="oracle_foundation", + concepts=("oracle",), + required_evidence=tuple(f"card:{name}" for name in card_names), + search_queries=tuple(card_names), + ), + ) + _renumber_hypotheses(hypotheses) + + intent_required, intent_queries = _intent_requirements(analysis, cards) + covered = { + _normalize_token(token) + for hypothesis in hypotheses + for token in hypothesis.required_evidence + } + uncovered = tuple( + token for token in intent_required + if _normalize_token(token) not in covered + ) + if uncovered or not hypotheses: + hypotheses.append( + InvestigationHypothesis( + hypothesis_id=f"hypothesis-{len(hypotheses) + 1}", + statement=( + "La base factual necesaria para responder está respaldada por evidencia del Juez." + if analysis.language == "es" + else "The factual basis needed for the answer is backed by Judge-owned evidence." + ), + kind="answer_basis", + concepts=tuple(analysis.concepts), + required_evidence=uncovered or intent_required, + search_queries=intent_queries, + ) + ) + + return hypotheses + + +def run_investigation( + *, + analysis: InputAnalysis, + requests: list[JudgeToolRequest], + hypotheses: list[InvestigationHypothesis], + execute: JudgeToolExecutor, + conversation, + budget: JudgeToolBudget, +) -> InvestigationOutcome: + outcome = InvestigationOutcome( + hypotheses=hypotheses, + initial_queries=len(requests), + ) + available_evidence: set[str] = set() + _evaluate_hypotheses(outcome.hypotheses, available_evidence) + + for request in requests: + if not _execute_step( + outcome, + request=request, + phase="initial_evidence", + hypothesis_ids=_related_hypotheses(request, outcome.hypotheses), + execute=execute, + conversation=conversation, + budget=budget, + available_evidence=available_evidence, + ): + outcome.stopped_reason = "budget_exceeded" + _finalize_outcome(outcome) + return outcome + + while True: + _finalize_outcome(outcome) + if outcome.sufficient: + outcome.stopped_reason = "evidence_sufficient" + return outcome + + candidate = _next_follow_up(analysis, outcome.hypotheses) + if candidate is None: + outcome.stopped_reason = "no_follow_up_available" + return outcome + + hypothesis, request, phase = candidate + hypothesis.follow_up_attempted = True + outcome.follow_up_queries += 1 + if not _execute_step( + outcome, + request=request, + phase=phase, + hypothesis_ids=(hypothesis.hypothesis_id,), + execute=execute, + conversation=conversation, + budget=budget, + available_evidence=available_evidence, + ): + outcome.stopped_reason = "budget_exceeded" + _finalize_outcome(outcome) + return outcome + + +def evidence_tokens_from_calls(tool_calls: list[dict[str, Any]]) -> set[str]: + tokens: set[str] = set() + for call in tool_calls: + for item in call.get("evidence", []): + if not isinstance(item, dict): + continue + kind = str(item.get("kind", "")).strip().casefold() + data = item.get("data", {}) + if not isinstance(data, dict): + data = {} + identifier = str(item.get("identifier", "")).strip() + if kind == "rule": + number = str(data.get("number", identifier)).strip() + if number: + tokens.add(_normalize_token(f"rule:{number}")) + elif kind == "card": + name = str(data.get("name", identifier)).strip() + if name: + tokens.add(_normalize_token(f"card:{name}")) + elif kind == "ruling": + card_name = str(data.get("card_name", "")).strip() + oracle_id = str(data.get("oracle_id", "")).strip() + if card_name: + tokens.add(_normalize_token(f"ruling:{card_name}")) + if oracle_id: + tokens.add(_normalize_token(f"ruling_oracle:{oracle_id}")) + if kind in {"rule", "card", "ruling"}: + tokens.add("evidence:authoritative") + return tokens + + +def _execute_step( + outcome: InvestigationOutcome, + *, + request: JudgeToolRequest, + phase: str, + hypothesis_ids: tuple[str, ...], + execute: JudgeToolExecutor, + conversation, + budget: JudgeToolBudget, + available_evidence: set[str], +) -> bool: + before = _overall_score(outcome.hypotheses) + result = execute(request, conversation=conversation, budget=budget) + payload = result.to_dict() + outcome.tool_calls.append(payload) + available_evidence.update(evidence_tokens_from_calls([payload])) + _evaluate_hypotheses(outcome.hypotheses, available_evidence) + after = _overall_score(outcome.hypotheses) + outcome.steps.append( + InvestigationStep( + sequence=len(outcome.steps) + 1, + phase=phase, + request=request, + status=str(payload.get("status", "")), + evidence_count=len(payload.get("evidence", [])), + hypothesis_ids=hypothesis_ids, + sufficiency_before=before, + sufficiency_after=after, + cache_hit=bool(payload.get("cache_hit", False)), + error_code=str(payload.get("error_code", "")), + budget=dict(payload.get("budget", {})), + ) + ) + return result.status is not JudgeToolStatus.BUDGET_EXCEEDED + + +def _next_follow_up( + analysis: InputAnalysis, + hypotheses: list[InvestigationHypothesis], +) -> tuple[InvestigationHypothesis, JudgeToolRequest, str] | None: + phase = ( + "counterexample_search" + if analysis.asks_for_validation + or analysis.speech_act in {SpeechAct.HYPOTHESIS, SpeechAct.CHALLENGE} + else "alternative_search" + ) + for hypothesis in hypotheses: + if hypothesis.status is HypothesisStatus.SUFFICIENT or hypothesis.follow_up_attempted: + continue + if not hypothesis.search_queries: + continue + + missing_rules = [ + token.split(":", 1)[1] + for token in hypothesis.missing_evidence + if token.startswith("rule:") + ] + if missing_rules: + return ( + hypothesis, + JudgeToolRequest( + tool="rules_search", + arguments={"query": hypothesis.search_queries[0], "limit": 8}, + purpose=f"resolve_{hypothesis.hypothesis_id}", + request_id=f"{hypothesis.hypothesis_id}-rules-search", + result_limit=8, + ), + phase, + ) + + missing_cards = [ + token.split(":", 1)[1] + for token in hypothesis.missing_evidence + if token.startswith("card:") + ] + if missing_cards: + return ( + hypothesis, + JudgeToolRequest( + tool="oracle_search", + arguments={"query": missing_cards[0], "limit": 8}, + purpose=f"resolve_{hypothesis.hypothesis_id}", + request_id=f"{hypothesis.hypothesis_id}-oracle-search", + result_limit=8, + ), + phase, + ) + return None + + +def _related_hypotheses( + request: JudgeToolRequest, + hypotheses: list[InvestigationHypothesis], +) -> tuple[str, ...]: + if request.tool == "oracle_lookup": + prefix = "card:" + elif request.tool in {"rules_lookup", "rules_search"}: + prefix = "rule:" + elif request.tool == "rulings_lookup": + related = [ + item.hypothesis_id + for item in hypotheses + if "ozolith" in item.concepts or item.kind == ClaimKind.TIMING_HYPOTHESIS.value + ] + return tuple(related) + else: + return tuple(item.hypothesis_id for item in hypotheses) + related = [ + item.hypothesis_id + for item in hypotheses + if any(token.startswith(prefix) for token in item.required_evidence) + ] + return tuple(related) + + +def _claim_requirements( + kind: ClaimKind, + concepts: tuple[str, ...], + card_names: list[str], +) -> tuple[tuple[str, ...], tuple[str, ...]]: + concept_set = set(concepts) + if kind is ClaimKind.MECHANIC_EQUIVALENCE: + return ( + ("rule:700.4", "rule:702.93a"), + ("dies put into a graveyard from the battlefield undying",), + ) + if kind is ClaimKind.STATE_TRANSITION: + return ( + ("rule:701.21a", "rule:700.4"), + ("sacrifice permanent creature dies graveyard",), + ) + if kind is ClaimKind.TIMING_HYPOTHESIS: + required = ["rule:122.2", "rule:400.7", "rule:603.6c", "rule:603.10a"] + if any(name.casefold() == "the ozolith" for name in card_names): + required.append("card:The Ozolith") + return ( + tuple(required), + ("counters cease to exist zone change leaves battlefield triggered ability",), + ) + if kind is ClaimKind.DERIVED_CONCLUSION: + return ( + ("rule:702.93a", "rule:603.4"), + ("undying intervening if last known information",), + ) + if kind is ClaimKind.STRATEGIC: + required = tuple(f"card:{name}" for name in card_names) + return ( + required or ("context:active_interaction",), + tuple(card_names), + ) + + required: list[str] = [] + if "sacrifice" in concept_set: + required.extend(["rule:701.21a", "rule:700.4"]) + if "undying" in concept_set: + required.extend(["rule:702.93a", "rule:603.4"]) + if "counters" in concept_set: + required.extend(["rule:122.2", "rule:400.7"]) + resolved_requirements = tuple(_deduplicate(required)) + return ( + resolved_requirements or ("evidence:authoritative",), + (" ".join(concepts),) if concepts else (), + ) + + +def _intent_requirements( + analysis: InputAnalysis, + cards: list[dict[str, Any]], +) -> tuple[tuple[str, ...], tuple[str, ...]]: + card_names = [str(card.get("name", "")).strip() for card in cards if card.get("name")] + oracle_text = " ".join(str(card.get("oracle_text", "")) for card in cards).casefold() + required = [f"card:{name}" for name in card_names] + concepts = set(analysis.concepts) + derive_card_mechanics = analysis.strategy_intent in { + StrategyIntent.COMBO_DETECTION, + StrategyIntent.SYNERGY_ANALYSIS, + StrategyIntent.INTERACTION_HYPOTHESIS, + StrategyIntent.COMBO_FAILURE_EXPLANATION, + StrategyIntent.INTERACTION_TIMING, + StrategyIntent.PLAY_SEQUENCE, + StrategyIntent.COMBO_DISRUPTION, + StrategyIntent.COMBO_REQUIREMENTS, + } + if "sacrifice" in concepts or (derive_card_mechanics and "sacrifice" in oracle_text): + required.extend(["rule:701.21a", "rule:700.4"]) + if "undying" in concepts or (derive_card_mechanics and "undying" in oracle_text): + required.extend(["rule:702.93a", "rule:603.4"]) + if "ozolith" in concepts or "counters" in concepts: + required.extend(["rule:122.2", "rule:400.7", "rule:603.6c", "rule:603.10a"]) + if ( + {"land_types", "basic_lands", "nonbasic_lands", "mana_abilities"} & concepts + and {"layers", "dependency", "timestamp"} & concepts + ): + required.extend([ + "rule:305.6", + "rule:305.7", + "rule:305.8", + "rule:611.3", + "rule:613.1d", + "rule:613.7", + "rule:613.8a", + "rule:613.8b", + ]) + query_parts = list(analysis.concepts) + if derive_card_mechanics and "sacrifice" in oracle_text and "sacrifice" not in query_parts: + query_parts.append("sacrifice") + if derive_card_mechanics and "undying" in oracle_text and "undying" not in query_parts: + query_parts.append("undying") + if ( + {"land_types", "basic_lands", "nonbasic_lands", "mana_abilities"} & concepts + and {"layers", "dependency", "timestamp"} & concepts + ): + query_parts.extend([ + "basic land types", + "intrinsic mana abilities", + "timestamp dependency layer 4", + ]) + query = " ".join(_deduplicate(query_parts)) or analysis.canonical_text + return tuple(_deduplicate(required)), ((query,) if query else ()) + + +def _evaluate_hypotheses( + hypotheses: list[InvestigationHypothesis], + available_evidence: set[str], +) -> None: + for hypothesis in hypotheses: + hypothesis.evaluate(available_evidence) + + +def _finalize_outcome(outcome: InvestigationOutcome) -> None: + outcome.sufficiency_score = _overall_score(outcome.hypotheses) + outcome.sufficient = bool(outcome.hypotheses) and all( + item.status is HypothesisStatus.SUFFICIENT + for item in outcome.hypotheses + ) + + +def _overall_score(hypotheses: list[InvestigationHypothesis]) -> float: + if not hypotheses: + return 0.0 + return round(sum(item.sufficiency_score for item in hypotheses) / len(hypotheses), 3) + + +def _normalize_token(token: str) -> str: + prefix, separator, value = str(token).partition(":") + if not separator: + return str(token).strip().casefold() + return f"{prefix.strip().casefold()}:{value.strip().casefold()}" + + +def _renumber_hypotheses(hypotheses: list[InvestigationHypothesis]) -> None: + for index, hypothesis in enumerate(hypotheses, start=1): + hypothesis.hypothesis_id = f"hypothesis-{index}" + + +def _deduplicate(items: list[str]) -> list[str]: + result: list[str] = [] + seen: set[str] = set() + for item in items: + key = str(item).casefold() + if not item or key in seen: + continue + seen.add(key) + result.append(item) + return result diff --git a/magicai/tactician/models.py b/magicai/tactician/models.py index 568be74..9f7e4d9 100644 --- a/magicai/tactician/models.py +++ b/magicai/tactician/models.py @@ -84,6 +84,7 @@ class TacticianResult: queries_completed: int = 0 judge_verified: bool = False investigation_plan: dict[str, Any] = field(default_factory=dict) + investigation_trace: dict[str, Any] = field(default_factory=dict) response_language: str = "es" language_policy: dict[str, Any] = field(default_factory=dict) answer_obligations: list[dict[str, Any]] = field(default_factory=list) @@ -137,6 +138,7 @@ def to_dict(self) -> dict[str, Any]: "queries_completed": int(self.queries_completed), "judge_verified": bool(self.judge_verified), "investigation_plan": dict(self.investigation_plan), + "investigation_trace": dict(self.investigation_trace), "response_language": self.response_language, "language_policy": dict(self.language_policy), "answer_obligations": list(self.answer_obligations), diff --git a/magicai/tactician/planner.py b/magicai/tactician/planner.py index 770e937..a72af1e 100644 --- a/magicai/tactician/planner.py +++ b/magicai/tactician/planner.py @@ -5,6 +5,7 @@ from magicai.judge_tools import JudgeToolRequest from magicai.tactician.input_analysis import InputAnalysis +from magicai.tactician.investigation import InvestigationHypothesis, build_hypotheses from magicai.tactician.intents import StrategyIntent @@ -12,12 +13,14 @@ class InvestigationPlan: requests: list[JudgeToolRequest] = field(default_factory=list) goals: list[str] = field(default_factory=list) + hypotheses: list[InvestigationHypothesis] = field(default_factory=list) def to_dict(self) -> dict[str, object]: return { "queries_planned": len(self.requests), "goals": list(self.goals), "requests": [request.to_dict() for request in self.requests], + "hypotheses": [hypothesis.to_dict() for hypothesis in self.hypotheses], } @@ -56,7 +59,21 @@ def plan_investigation( "Relacionar el evento de morir con Undying y excluir entradas al cementerio desde otras zonas." if spanish else "Relate dying to Undying and exclude graveyard entry from other zones.", ]) else: - if "sacrifice" in concepts: + derive_card_mechanics = intent in { + StrategyIntent.COMBO_DETECTION, + StrategyIntent.SYNERGY_ANALYSIS, + StrategyIntent.INTERACTION_HYPOTHESIS, + StrategyIntent.COMBO_FAILURE_EXPLANATION, + StrategyIntent.INTERACTION_TIMING, + StrategyIntent.PLAY_SEQUENCE, + StrategyIntent.COMBO_DISRUPTION, + StrategyIntent.COMBO_REQUIREMENTS, + } + has_sacrifice_text = any( + "sacrifice" in str(card.get("oracle_text", "")).casefold() + for card in cards + ) + if "sacrifice" in concepts or (derive_card_mechanics and has_sacrifice_text): rules.extend(["701.21a", "700.4"]) goals.append( "Verificar la transición de sacrificar a morir." @@ -78,6 +95,37 @@ def plan_investigation( "Verify the Undying trigger condition." ) + land_layer_concepts = { + "land_types", + "basic_lands", + "nonbasic_lands", + "mana_abilities", + } & concepts + ordering_concepts = {"layers", "dependency", "timestamp"} & concepts + if land_layer_concepts and ordering_concepts: + rules.extend([ + "305.6", + "305.7", + "305.8", + "611.3", + "613.1d", + "613.7", + "613.8a", + "613.8b", + ]) + goals.extend([ + ( + "Distinguir entre fijar un tipo de tierra y añadir tipos en la capa 4." + if spanish else + "Distinguish setting a land type from adding land types in layer 4." + ), + ( + "Aplicar dependencias antes de marcas de tiempo y derivar las habilidades de maná resultantes." + if spanish else + "Apply dependencies before timestamps and derive the resulting mana abilities." + ), + ]) + if intent in {StrategyIntent.INTERACTION_TIMING, StrategyIntent.COMBO_FAILURE_EXPLANATION}: rules.extend(["603.4", "603.6c", "603.10a", "400.7"]) goals.append( @@ -130,7 +178,12 @@ def plan_investigation( "Check official rulings for The Ozolith." ) - return InvestigationPlan(requests=requested_unique(requests), goals=_deduplicate(goals)) + hypotheses = build_hypotheses(analysis, cards=cards) + return InvestigationPlan( + requests=requested_unique(requests), + goals=_deduplicate(goals), + hypotheses=hypotheses, + ) def requested_unique(requests: list[JudgeToolRequest]) -> list[JudgeToolRequest]: diff --git a/magicai/validation/rule_renderer.py b/magicai/validation/rule_renderer.py index 937e26e..2b6434a 100644 --- a/magicai/validation/rule_renderer.py +++ b/magicai/validation/rule_renderer.py @@ -59,7 +59,11 @@ "pt_modify_layer": ["613.4c"], "layer_continuity": ["613.6"], "dependency": ["613.8"], + "timestamp": ["613.7"], + "dependency_definition": ["613.8a", "613.8b"], + "basic_land_mana": ["305.6"], "basic_land_type": ["305.7"], + "basic_land_supertype": ["305.8"], "zone_change": ["400.7"], "persist": ["702.79", "702.79a"], "undying": ["702.93", "702.93a"], @@ -151,6 +155,14 @@ def render_rule_answer(knowledge: str) -> str | None: "primero es gratuito." ) + land_type_answer = _render_basic_land_type_layer_interaction( + knowledge, + q, + ) + + if land_type_answer is not None: + return land_type_answer + layered_answer = _render_layered_static_source_comparison( knowledge, q, @@ -674,6 +686,311 @@ def render_rule_answer(knowledge: str) -> str | None: return None + +def _render_basic_land_type_layer_interaction( + knowledge: str, + question: str, +) -> str | None: + """Resolve generic layer-4 interactions between land-type setters/adders. + + The renderer recognizes Oracle patterns rather than card names: + - a global effect that sets nonbasic lands to one basic land type; + - a controller-scoped effect that adds every basic land type; + - a static ability on a nonbasic land that adds one basic land type. + + It then applies rules 305.6-305.8, timestamp order, and dependency. + """ + + if not _looks_like_basic_land_type_layer_question(question): + return None + + if not _has_rules( + knowledge, + [ + "continuous_static", + "type_layer", + "timestamp", + "dependency_definition", + "basic_land_mana", + "basic_land_type", + "basic_land_supertype", + ], + ): + return None + + entries = _extract_card_entries(knowledge) + setter = _find_nonbasic_land_type_setter(entries) + controller_adder = _find_controller_all_basic_type_adder(entries) + global_adder = _find_global_basic_type_adder(entries) + + if not setter or not controller_adder or not global_adder: + return None + + setter_name, setter_type = setter + controller_name = controller_adder[0] + global_name, global_type = global_adder + + # A source whose own type line is a nonbasic land is affected by the + # setter. Applying the setter therefore removes the source's printed + # static ability and changes the existence of the additive effect. + global_entry_text = next( + (entry_text for name, entry_text in entries if name == global_name), + "", + ) + if not _is_nonbasic_land_entry(global_entry_text): + return None + + setter_before_controller = _name_appears_before( + question, + setter_name, + controller_name, + ) + + setter_type_es = _basic_type_name(setter_type, language="es") + setter_color_es = _basic_type_color(setter_type, language="es") + global_type_es = _basic_type_name(global_type, language="es") + + if _looks_spanish(question): + if setter_before_controller: + controlled_nonbasic = ( + f"{setter_name} se aplica primero y las convierte en {setter_type_es}, " + f"eliminando sus tipos anteriores y las habilidades impresas. Después " + f"{controller_name} añade los cinco tipos básicos. Terminan siendo " + "Llanura, Isla, Pantano, Montaña y Bosque, con las cinco habilidades " + "intrínsecas de maná; pueden producir W, U, B, R o G. Las habilidades " + "impresas que eliminó el primer efecto no regresan." + ) + else: + controlled_nonbasic = ( + f"{controller_name} añade primero los cinco tipos básicos, pero " + f"{setter_name} se aplica después y fija el tipo en {setter_type_es}. " + f"Terminan siendo solo {setter_type_es}, pierden las habilidades " + f"impresas y solo pueden producir {setter_color_es}." + ) + + reversed_result = ( + f"Si {controller_name} hubiera entrado antes que {setter_name}, el orden " + f"entre esos dos efectos independientes se invertiría: {controller_name} " + f"añadiría primero los cinco tipos y {setter_name} los sustituiría después " + f"por {setter_type_es}. Tus tierras no básicas terminarían siendo solo " + f"{setter_type_es} y solo producirían {setter_color_es}." + if setter_before_controller else + f"Si {controller_name} entrara después que {setter_name}, añadiría los " + f"cinco tipos básicos tras el efecto que las fija como {setter_type_es}; " + "tus tierras no básicas podrían producir los cinco colores." + ) + + return ( + "Los tres efectos se aplican en la capa 4, pero no se ordenan todos " + "únicamente por timestamp.\n\n" + f"**Dependencia.** El efecto de {global_name} depende del de {setter_name}. " + f"Al aplicar {setter_name}, {global_name} —que es una tierra no básica— " + f"pasa a ser {setter_type_es}; la regla 305.7 elimina su habilidad impresa. " + "Eso cambia la existencia del otro efecto, así que la dependencia prevalece " + "sobre la marca de tiempo: el efecto que fija el tipo se aplica primero y " + f"{global_name} no llega a convertir las tierras en {global_type_es}. Su " + "orden de entrada no cambia esta conclusión.\n\n" + f"**{controller_name} frente a {setter_name}.** Entre estos dos no hay " + "dependencia: añadir tipos básicos no añade el supertipo «básica», por lo " + "que las tierras no básicas siguen dentro del alcance del efecto que fija " + "su tipo. Aquí sí decide el timestamp.\n\n" + "1. **Tus tierras básicas:** el efecto sobre tierras no básicas no las " + f"afecta. {controller_name} les añade todos los tipos básicos además de " + "los que ya tengan. Conservan el supertipo básica y pueden producir los " + "cinco colores: W, U, B, R o G.\n" + f"2. **Tus tierras no básicas:** {controlled_nonbasic}\n" + f"3. **Las tierras no básicas del oponente:** {controller_name} no las " + f"afecta y {global_name} está desactivada por dependencia. Terminan siendo " + f"solo {setter_type_es}, pierden sus habilidades impresas y solo pueden " + f"producir {setter_color_es}.\n\n" + f"**Orden alternativo:** {reversed_result}\n\n" + f"Por tanto, «{setter_name} siempre gana» solo es correcto respecto al " + f"efecto de {global_name}; no lo es frente al efecto de {controller_name}. " + "Y tampoco es cierto que todo se resuelva únicamente por timestamp: primero " + "se aplican las dependencias y el timestamp ordena los efectos independientes." + ) + + setter_type_en = _basic_type_name(setter_type, language="en") + setter_color_en = _basic_type_color(setter_type, language="en") + global_type_en = _basic_type_name(global_type, language="en") + if setter_before_controller: + controlled_nonbasic = ( + f"{setter_name} applies first and sets them to {setter_type_en}, removing " + f"their old land types and printed abilities. {controller_name} then adds " + "all five basic land types. They can produce W, U, B, R, or G, but the " + "printed abilities removed by the setter do not return." + ) + else: + controlled_nonbasic = ( + f"{controller_name} adds all five basic land types first, then {setter_name} " + f"sets them to {setter_type_en}. They end as {setter_type_en} only, lose " + f"their printed abilities, and can produce only {setter_color_en}." + ) + + return ( + "All three effects apply in layer 4, but they are not all ordered only " + "by timestamp.\n\n" + f"**Dependency.** {global_name}'s effect depends on {setter_name}'s effect. " + f"Applying {setter_name} turns the nonbasic land source into {setter_type_en} " + "and rule 305.7 removes its printed static ability. Dependency therefore " + "overrides timestamp, so the global additive effect does not apply, regardless " + "of their entry order.\n\n" + f"**{controller_name} versus {setter_name}.** These effects are independent. " + "Adding basic land types does not add the basic supertype, so nonbasic lands " + "remain nonbasic. Timestamp orders these two effects.\n\n" + f"1. **Your basic lands:** {controller_name} gives them every basic land type, " + "so they can produce W, U, B, R, or G.\n" + f"2. **Your nonbasic lands:** {controlled_nonbasic}\n" + f"3. **The opponent's nonbasic lands:** they are {setter_type_en} only, lose " + f"their printed abilities, and can produce only {setter_color_en}.\n\n" + f"If {controller_name} entered before {setter_name}, your nonbasic lands would " + f"end as {setter_type_en} only and produce only {setter_color_en}. " + f"The claim that {setter_name} always wins is true only against {global_name}; " + "the claim that everything is timestamp-only is false because dependency is " + "applied first." + ) + + +def _looks_like_basic_land_type_layer_question(question: str) -> bool: + has_land_scope = any( + marker in question + for marker in ( + "tipo de tierra", + "tipos de tierra", + "tierras basicas", + "tierras no basicas", + "land type", + "land types", + "basic lands", + "nonbasic lands", + ) + ) + has_ordering = any( + marker in question + for marker in ( + "capa", + "layer", + "dependencia", + "dependency", + "timestamp", + "marca de tiempo", + ) + ) + has_mana = any( + marker in question + for marker in ( + "habilidades de mana", + "habilidad de mana", + "pueden producir", + "mana abilities", + "can produce", + ) + ) + return has_land_scope and has_ordering and has_mana + + +def _find_nonbasic_land_type_setter( + entries: list[tuple[str, str]], +) -> tuple[str, str] | None: + pattern = re.compile( + r"\bnonbasic lands are (plains|islands|swamps|mountains|forests)\b" + ) + for name, entry_text in entries: + match = pattern.search(_normalize(entry_text)) + if match: + return name, _singular_basic_type(match.group(1)) + return None + + +def _find_controller_all_basic_type_adder( + entries: list[tuple[str, str]], +) -> tuple[str, str] | None: + for entry in entries: + normalized = _normalize(entry[1]) + if ( + "lands you control are every basic land type" in normalized + and "in addition to their other types" in normalized + ): + return entry + return None + + +def _find_global_basic_type_adder( + entries: list[tuple[str, str]], +) -> tuple[str, str] | None: + pattern = re.compile( + r"\beach land is (?:a|an) (plains|island|swamp|mountain|forest) " + r"in addition to its other land types\b" + ) + for name, entry_text in entries: + match = pattern.search(_normalize(entry_text)) + if match: + return name, _singular_basic_type(match.group(1)) + return None + + +def _is_nonbasic_land_entry(entry_text: str) -> bool: + type_line = _normalize(entry_text.splitlines()[0] if entry_text else "") + return "land" in type_line and "basic land" not in type_line + + +def _name_appears_before(question: str, first: str, second: str) -> bool: + first_index = question.find(_normalize(first)) + second_index = question.find(_normalize(second)) + if first_index < 0 or second_index < 0: + return True + return first_index < second_index + + +def _singular_basic_type(value: str) -> str: + return { + "plains": "plains", + "islands": "island", + "swamps": "swamp", + "mountains": "mountain", + "forests": "forest", + }.get(value, value) + + +def _basic_type_name(value: str, *, language: str) -> str: + names = { + "plains": ("Llanura", "Plains"), + "island": ("Isla", "Island"), + "swamp": ("Pantano", "Swamp"), + "mountain": ("Montaña", "Mountain"), + "forest": ("Bosque", "Forest"), + } + spanish, english = names.get(value, (value.title(), value.title())) + return spanish if language == "es" else english + + +def _basic_type_color(value: str, *, language: str) -> str: + colors = { + "plains": ("maná blanco (W)", "white mana (W)"), + "island": ("maná azul (U)", "blue mana (U)"), + "swamp": ("maná negro (B)", "black mana (B)"), + "mountain": ("maná rojo (R)", "red mana (R)"), + "forest": ("maná verde (G)", "green mana (G)"), + } + spanish, english = colors.get(value, (value, value)) + return spanish if language == "es" else english + + +def _looks_spanish(question: str) -> bool: + return any( + marker in question + for marker in ( + "tierras", + "oponente", + "capa", + "dependencia", + "pueden producir", + "habria", + "habría", + ) + ) + + def _render_layered_static_source_comparison( knowledge: str, question: str, diff --git a/magicai/versioning.py b/magicai/versioning.py index 03aafb8..edc775d 100644 --- a/magicai/versioning.py +++ b/magicai/versioning.py @@ -2,8 +2,8 @@ JUDGE_RESULT_SCHEMA_VERSION = "1.0" JUDGE_TOOL_RESULT_SCHEMA_VERSION = "1.0" -API_CONTRACT_VERSION = "1.7" -TACTICIAN_RESULT_SCHEMA_VERSION = "0.6" +API_CONTRACT_VERSION = "1.8" +TACTICIAN_RESULT_SCHEMA_VERSION = "0.7" # Packaging metadata must follow PEP 440. Public release names remain SemVer-like. PACKAGE_FALLBACK_VERSION = "0.1.1b0" diff --git a/pyproject.toml b/pyproject.toml index 52c4bfd..45aaa82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,24 +1,28 @@ -[build-system] -requires = ["setuptools>=83.0.0"] -build-backend = "setuptools.build_meta" - -[project] -name = "magicai" -version = "0.1.1b0" -description = "Local, source-grounded Magic: The Gathering Judge assistant" -readme = "README.md" -requires-python = ">=3.12" -license = "AGPL-3.0-or-later" -dependencies = [ - "fastapi>=0.139.0,<0.140.0", - "pydantic>=2.13.4,<3.0.0", - "requests>=2.34.2,<3.0.0", - "uvicorn>=0.50.2,<0.52.0", -] - -[tool.setuptools.packages.find] -include = ["magicai*"] -namespaces = true - -[tool.setuptools.package-data] -magicai = ["ui/static/*.html", "ui/static/*.css", "ui/static/*.js"] +[build-system] +requires = ["setuptools>=83.0.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "magicai" +version = "0.1.1b0" +description = "Local, source-grounded Magic: The Gathering Judge assistant" +readme = "README.md" +requires-python = ">=3.12" +license = "AGPL-3.0-or-later" +dependencies = [ + "fastapi>=0.139.0,<0.140.0", + "pydantic>=2.13.4,<3.0.0", + "requests>=2.34.2,<3.0.0", + "uvicorn>=0.50.2,<0.52.0", +] + +[tool.setuptools.packages.find] +include = ["magicai*"] +namespaces = true + +[tool.setuptools.package-data] +magicai = ["ui/static/*.html", "ui/static/*.css", "ui/static/*.js"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +norecursedirs = ["backups"] diff --git a/scripts/ci_check.py b/scripts/ci_check.py index 67f7c0e..bbeaf32 100644 --- a/scripts/ci_check.py +++ b/scripts/ci_check.py @@ -22,6 +22,8 @@ "tests.judge_tools.local_tools_test", "tests.api.judge_tool_api_test", "tests.tactician.tactician_tool_gateway_test", + "tests.tactician.tactician_autonomous_investigation_test", + "tests.tactician.tactician_land_type_investigation_regression_test", "tests.tactician.tactician_input_reasoning_test", "tests.tactician.tactician_response_orchestration_test", "tests.tactician.tactician_followup_reasoning_test", @@ -44,6 +46,7 @@ "tests.quality.tactician_feedback_promotion_test", "tests.validation.oracle_derived_undying_test", "tests.validation.rule_renderer_test", + "tests.validation.rule_renderer_land_type_layers_test", "tests.ui.ui_assets_test", "tests.ui.ui_usability_test", ) diff --git a/tests/api/api_contract_metadata_test.py b/tests/api/api_contract_metadata_test.py index f615198..5e681fb 100644 --- a/tests/api/api_contract_metadata_test.py +++ b/tests/api/api_contract_metadata_test.py @@ -1,90 +1,90 @@ -from magicai.api import routes -from magicai.api.health import ServiceProbe, build_health_payload -from magicai.sources.health import SourceHealth, SourceProbe -from magicai.versioning import ( - API_CONTRACT_VERSION, - JUDGE_RESULT_SCHEMA_VERSION, - PUBLIC_VERSION, - RELEASE_CODENAME, - RELEASE_TAG, -) - - -def _source_health() -> SourceHealth: - probes = { - "scryfall_oracle": SourceProbe( - name="scryfall_oracle", - status="available", - required=True, - ), - "comprehensive_rules": SourceProbe( - name="comprehensive_rules", - status="available", - required=True, - ), - "scryfall_symbology": SourceProbe( - name="scryfall_symbology", - status="available", - required=False, - ), - "scryfall_rulings": SourceProbe( - name="scryfall_rulings", - status="available", - required=False, - ), - } - return SourceHealth( - status="ready", - ready=True, - complete=True, - sources=probes, - ) - - -def test_meta_exposes_stable_contract_values() -> None: - payload = routes.meta() - - assert payload["project_version"] == PUBLIC_VERSION - assert payload["release_codename"] == RELEASE_CODENAME - assert payload["release_tag"] == RELEASE_TAG - assert payload["api_contract_version"] == API_CONTRACT_VERSION - assert payload["judge_result_schema_version"] == JUDGE_RESULT_SCHEMA_VERSION - assert "answered" in payload["judge_statuses"] - assert "deterministic_rulings" in payload["judge_origins"] - assert payload["authority"] == "judge" - - -def test_health_payload_distinguishes_ready_and_full_service() -> None: - payload = build_health_payload( - source_health=_source_health(), - ollama_probe=ServiceProbe( - status="unavailable", - available=False, - detail="test", - model="qwen3:8b", - ), - ) - - assert payload["project_version"] == PUBLIC_VERSION - assert payload["release_codename"] == RELEASE_CODENAME - assert payload["release_tag"] == RELEASE_TAG - assert payload["status"] == "degraded" - assert payload["ready"] is True - assert payload["full_service"] is False - assert payload["sources"]["ready"] is True - - -def main() -> int: - tests = [ - test_meta_exposes_stable_contract_values, - test_health_payload_distinguishes_ready_and_full_service, - ] - for test in tests: - test() - print(f"OK: {test.__name__}") - print(f"API metadata tests: {len(tests)}/{len(tests)}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) +from magicai.api import routes +from magicai.api.health import ServiceProbe, build_health_payload +from magicai.sources.health import SourceHealth, SourceProbe +from magicai.versioning import ( + API_CONTRACT_VERSION, + JUDGE_RESULT_SCHEMA_VERSION, + PUBLIC_VERSION, + RELEASE_CODENAME, + RELEASE_TAG, +) + + +def _source_health() -> SourceHealth: + probes = { + "scryfall_oracle": SourceProbe( + name="scryfall_oracle", + status="available", + required=True, + ), + "comprehensive_rules": SourceProbe( + name="comprehensive_rules", + status="available", + required=True, + ), + "scryfall_symbology": SourceProbe( + name="scryfall_symbology", + status="available", + required=False, + ), + "scryfall_rulings": SourceProbe( + name="scryfall_rulings", + status="available", + required=False, + ), + } + return SourceHealth( + status="ready", + ready=True, + complete=True, + sources=probes, + ) + + +def test_meta_exposes_stable_contract_values() -> None: + payload = routes.meta() + + assert payload["project_version"] == PUBLIC_VERSION + assert payload["release_codename"] == RELEASE_CODENAME + assert payload["release_tag"] == RELEASE_TAG + assert payload["api_contract_version"] == API_CONTRACT_VERSION + assert payload["judge_result_schema_version"] == JUDGE_RESULT_SCHEMA_VERSION + assert "answered" in payload["judge_statuses"] + assert "deterministic_rulings" in payload["judge_origins"] + assert payload["authority"] == "judge" + + +def test_health_payload_distinguishes_ready_and_full_service() -> None: + payload = build_health_payload( + source_health=_source_health(), + ollama_probe=ServiceProbe( + status="unavailable", + available=False, + detail="test", + model="qwen3:8b", + ), + ) + + assert payload["project_version"] == PUBLIC_VERSION + assert payload["release_codename"] == RELEASE_CODENAME + assert payload["release_tag"] == RELEASE_TAG + assert payload["status"] == "degraded" + assert payload["ready"] is True + assert payload["full_service"] is False + assert payload["sources"]["ready"] is True + + +def main() -> int: + tests = [ + test_meta_exposes_stable_contract_values, + test_health_payload_distinguishes_ready_and_full_service, + ] + for test in tests: + test() + print(f"OK: {test.__name__}") + print(f"API metadata tests: {len(tests)}/{len(tests)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/api/tactician_api_contract_test.py b/tests/api/tactician_api_contract_test.py index e882098..d4bdf61 100644 --- a/tests/api/tactician_api_contract_test.py +++ b/tests/api/tactician_api_contract_test.py @@ -59,6 +59,11 @@ def test_tactician_response_remains_judge_evidence_compatible() -> None: queries_completed=2, judge_verified=True, investigation_plan={"queries_planned": 2}, + investigation_trace={ + "sufficient": True, + "sufficiency_score": 1.0, + "stopped_reason": "evidence_sufficient", + }, response_language="es", language_policy={"response_language": "es", "language_locked": True}, answer_obligations=[{"code": "direct_user_question", "required": True}], @@ -82,6 +87,7 @@ def test_tactician_response_remains_judge_evidence_compatible() -> None: assert payload["input_analysis"]["speech_act"] == "question" assert payload["claim_verdicts"][0]["verdict"] == "supported" assert payload["judge_verified"] is True + assert payload["investigation_trace"]["sufficiency_score"] == 1.0 assert payload["response_language"] == "es" assert payload["answer_complete"] is True assert payload["answer_obligations"][0]["code"] == "direct_user_question" diff --git a/tests/repository/__init__.py b/tests/repository/__init__.py index 4eefc1e..704f7ad 100644 --- a/tests/repository/__init__.py +++ b/tests/repository/__init__.py @@ -1 +1 @@ -"""Repository health tests.""" +"""Repository health tests.""" diff --git a/tests/repository/release_packaging_test.py b/tests/repository/release_packaging_test.py index 57031eb..f3b5924 100644 --- a/tests/repository/release_packaging_test.py +++ b/tests/repository/release_packaging_test.py @@ -1,120 +1,120 @@ -from __future__ import annotations - -import importlib.util -from pathlib import Path -import shutil -import subprocess -import sys -import tempfile -import zipfile - - -ROOT = Path(__file__).resolve().parents[2] -SCRIPT = ROOT / "scripts" / "package_release.py" - - -def load_packager(): - spec = importlib.util.spec_from_file_location("magicai_package_release", SCRIPT) - assert spec is not None and spec.loader is not None - module = importlib.util.module_from_spec(spec) - sys.modules[spec.name] = module - spec.loader.exec_module(module) - return module - - -def run(*arguments: str, cwd: Path) -> None: - subprocess.run(arguments, cwd=cwd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - - -def build_fixture(root: Path) -> None: - (root / "magicai").mkdir(parents=True) - (root / "sources" / "scryfall").mkdir(parents=True) - (root / "logs").mkdir(parents=True) - - shutil.copy2(ROOT / "magicai" / "versioning.py", root / "magicai" / "versioning.py") - shutil.copy2(ROOT / "pyproject.toml", root / "pyproject.toml") - (root / "README.md").write_text("tracked\n", encoding="utf-8") - (root / "tracked.txt").write_text("tracked content\n", encoding="utf-8") - (root / "private.txt").write_text("untracked private content\n", encoding="utf-8") - (root / "logs" / "tracked.log").write_text("must not ship\n", encoding="utf-8") - (root / "sources" / "scryfall" / "symbology.json").write_text("{}\n", encoding="utf-8") - (root / "sources" / "scryfall" / "oracle-cards.json").write_text("[{\"name\":\"A\"}]\n", encoding="utf-8") - (root / "sources" / "scryfall" / "rulings.json").write_text("[]\n", encoding="utf-8") - - run("git", "init", "-q", cwd=root) - run("git", "config", "user.name", "MagicAI Test", cwd=root) - run("git", "config", "user.email", "test@example.invalid", cwd=root) - run( - "git", - "add", - "README.md", - "tracked.txt", - "logs/tracked.log", - "pyproject.toml", - "magicai/versioning.py", - "sources/scryfall/symbology.json", - cwd=root, - ) - run("git", "commit", "-q", "-m", "fixture", cwd=root) - - -def archive_names(path: Path) -> set[str]: - with zipfile.ZipFile(path) as archive: - return set(archive.namelist()) - - -def test_source_and_full_packages_are_clean_and_deterministic() -> None: - packager = load_packager() - with tempfile.TemporaryDirectory(prefix="magicai-package-test-") as directory: - repo = Path(directory) / "repo" - repo.mkdir() - build_fixture(repo) - - first = Path(directory) / "first" - second = Path(directory) / "second" - source_a = packager.build_archive(repo=repo, mode="source", output_dir=first, force=False) - source_b = packager.build_archive(repo=repo, mode="source", output_dir=second, force=False) - full = packager.build_archive(repo=repo, mode="full", output_dir=first, force=False) - - source_root = "MagicAI-v0.1.1-beta-source/" - full_root = "MagicAI-v0.1.1-beta-full/" - source_names = archive_names(source_a) - full_names = archive_names(full) - - assert source_root + "README.md" in source_names - assert source_root + "tracked.txt" in source_names - assert source_root + "sources/scryfall/symbology.json" in source_names - assert source_root + "INFO.txt" in source_names - assert source_root + "PACKAGE_MANIFEST.json" in source_names - assert source_root + "private.txt" not in source_names - assert source_root + "logs/tracked.log" not in source_names - assert source_root + "sources/scryfall/oracle-cards.json" not in source_names - assert source_root + "sources/scryfall/rulings.json" not in source_names - - assert full_root + "sources/scryfall/oracle-cards.json" in full_names - assert full_root + "sources/scryfall/rulings.json" in full_names - assert source_a.read_bytes() == source_b.read_bytes() - - (repo / "tracked.txt").write_text("modified\n", encoding="utf-8") - try: - packager.build_archive( - repo=repo, - mode="source", - output_dir=Path(directory) / "dirty", - force=False, - ) - except RuntimeError as error: - assert "Tracked files are modified" in str(error) - else: - raise AssertionError("Dirty tracked files must be rejected by default") - - -def main() -> int: - test_source_and_full_packages_are_clean_and_deterministic() - print("OK: test_source_and_full_packages_are_clean_and_deterministic") - print("Release packaging tests: 1/1") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) +from __future__ import annotations + +import importlib.util +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import zipfile + + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = ROOT / "scripts" / "package_release.py" + + +def load_packager(): + spec = importlib.util.spec_from_file_location("magicai_package_release", SCRIPT) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def run(*arguments: str, cwd: Path) -> None: + subprocess.run(arguments, cwd=cwd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + +def build_fixture(root: Path) -> None: + (root / "magicai").mkdir(parents=True) + (root / "sources" / "scryfall").mkdir(parents=True) + (root / "logs").mkdir(parents=True) + + shutil.copy2(ROOT / "magicai" / "versioning.py", root / "magicai" / "versioning.py") + shutil.copy2(ROOT / "pyproject.toml", root / "pyproject.toml") + (root / "README.md").write_text("tracked\n", encoding="utf-8") + (root / "tracked.txt").write_text("tracked content\n", encoding="utf-8") + (root / "private.txt").write_text("untracked private content\n", encoding="utf-8") + (root / "logs" / "tracked.log").write_text("must not ship\n", encoding="utf-8") + (root / "sources" / "scryfall" / "symbology.json").write_text("{}\n", encoding="utf-8") + (root / "sources" / "scryfall" / "oracle-cards.json").write_text("[{\"name\":\"A\"}]\n", encoding="utf-8") + (root / "sources" / "scryfall" / "rulings.json").write_text("[]\n", encoding="utf-8") + + run("git", "init", "-q", cwd=root) + run("git", "config", "user.name", "MagicAI Test", cwd=root) + run("git", "config", "user.email", "test@example.invalid", cwd=root) + run( + "git", + "add", + "README.md", + "tracked.txt", + "logs/tracked.log", + "pyproject.toml", + "magicai/versioning.py", + "sources/scryfall/symbology.json", + cwd=root, + ) + run("git", "commit", "-q", "-m", "fixture", cwd=root) + + +def archive_names(path: Path) -> set[str]: + with zipfile.ZipFile(path) as archive: + return set(archive.namelist()) + + +def test_source_and_full_packages_are_clean_and_deterministic() -> None: + packager = load_packager() + with tempfile.TemporaryDirectory(prefix="magicai-package-test-") as directory: + repo = Path(directory) / "repo" + repo.mkdir() + build_fixture(repo) + + first = Path(directory) / "first" + second = Path(directory) / "second" + source_a = packager.build_archive(repo=repo, mode="source", output_dir=first, force=False) + source_b = packager.build_archive(repo=repo, mode="source", output_dir=second, force=False) + full = packager.build_archive(repo=repo, mode="full", output_dir=first, force=False) + + source_root = "MagicAI-v0.1.1-beta-source/" + full_root = "MagicAI-v0.1.1-beta-full/" + source_names = archive_names(source_a) + full_names = archive_names(full) + + assert source_root + "README.md" in source_names + assert source_root + "tracked.txt" in source_names + assert source_root + "sources/scryfall/symbology.json" in source_names + assert source_root + "INFO.txt" in source_names + assert source_root + "PACKAGE_MANIFEST.json" in source_names + assert source_root + "private.txt" not in source_names + assert source_root + "logs/tracked.log" not in source_names + assert source_root + "sources/scryfall/oracle-cards.json" not in source_names + assert source_root + "sources/scryfall/rulings.json" not in source_names + + assert full_root + "sources/scryfall/oracle-cards.json" in full_names + assert full_root + "sources/scryfall/rulings.json" in full_names + assert source_a.read_bytes() == source_b.read_bytes() + + (repo / "tracked.txt").write_text("modified\n", encoding="utf-8") + try: + packager.build_archive( + repo=repo, + mode="source", + output_dir=Path(directory) / "dirty", + force=False, + ) + except RuntimeError as error: + assert "Tracked files are modified" in str(error) + else: + raise AssertionError("Dirty tracked files must be rejected by default") + + +def main() -> int: + test_source_and_full_packages_are_clean_and_deterministic() + print("OK: test_source_and_full_packages_are_clean_and_deterministic") + print("Release packaging tests: 1/1") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/repository/repository_health_test.py b/tests/repository/repository_health_test.py index fd82583..91926c6 100644 --- a/tests/repository/repository_health_test.py +++ b/tests/repository/repository_health_test.py @@ -1,118 +1,118 @@ -from __future__ import annotations - -from pathlib import Path -import re -import tomllib - -from magicai.versioning import ( - NEXT_BETA_CODENAME, - NEXT_BETA_VERSION, - PACKAGE_FALLBACK_VERSION, - PUBLIC_VERSION, - RELEASE_CHANNEL, - RELEASE_CODENAME, - RELEASE_TAG, - V1_CODENAME, -) - - -ROOT = Path(__file__).resolve().parents[2] - - -def test_release_identity_is_consistent() -> None: - pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) - assert pyproject["project"]["version"] == PACKAGE_FALLBACK_VERSION - assert PUBLIC_VERSION == "0.1.1-beta" - assert RELEASE_TAG == "v0.1.1-beta" - assert RELEASE_CHANNEL == "beta" - assert RELEASE_CODENAME == "Force of Will" - assert NEXT_BETA_VERSION == "0.2.0-beta" - assert NEXT_BETA_CODENAME == "Ponder" - assert V1_CODENAME == "NicolAI Bolas" - - -def test_required_community_and_automation_files_exist() -> None: - required = [ - ".github/workflows/ci.yml", - ".github/dependabot.yml", - ".github/PULL_REQUEST_TEMPLATE.md", - "SECURITY.md", - "CODE_OF_CONDUCT.md", - "SUPPORT.md", - "docs/BRANCHING.md", - "docs/RELEASE_PROCESS.md", - "docs/REPOSITORY_HEALTH.md", - "scripts/ci_check.py", - "scripts/package_release.py", - "scripts/export_github_analysis.sh", - ] - missing = [relative for relative in required if not (ROOT / relative).is_file()] - assert not missing, f"Missing repository-health files: {missing}" - - -def test_ci_is_fast_and_does_not_require_bulk_sources_or_ollama() -> None: - workflow = (ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8") - assert "python scripts/ci_check.py" in workflow - assert "download_sources.sh" not in workflow - assert "oracle_exhaustive_test" not in workflow - assert "ollama pull" not in workflow - assert "permissions:\n contents: read" in workflow - - -def test_release_packaging_uses_git_and_ignores_local_exports() -> None: - script = (ROOT / "scripts/package_release.py").read_text(encoding="utf-8") - gitignore = (ROOT / ".gitignore").read_text(encoding="utf-8") - exporter = (ROOT / "scripts/export_github_analysis.sh").read_text(encoding="utf-8") - - assert '"ls-files", "-z"' in script - assert "FULL_SOURCE_FILES" in script - assert "/github-analysis-*/" in gitignore - assert "/dist/releases/" in gitignore - assert "--slurp" not in exporter - assert "if len(parts) < 6" in exporter - assert '--list-file="$OUT/git/tracked_files.txt"' in exporter - - -def test_markdown_is_english_and_personal_letter_is_preserved() -> None: - markdown_files = sorted( - path - for path in ROOT.rglob("*.md") - if not any(part in {"backups", "github-analysis-20260715-025523"} for part in path.parts) - ) - spanish_markers = re.compile( - r"[¿¡]|\b(?:por motivos|si has llegado|nos vemos|me gustaría|mientras mi salud|gracias por dedicar)\b", - flags=re.IGNORECASE, - ) - violations = [] - for path in markdown_files: - content = path.read_text(encoding="utf-8") - if spanish_markers.search(content): - violations.append(str(path.relative_to(ROOT))) - assert not violations, f"Markdown must remain English-only: {violations}" - - readme = (ROOT / "README.md").read_text(encoding="utf-8") - assert "# ❤️ A personal letter" in readme - assert "Due to health reasons" in readme - assert "See you in the next game." in readme - assert "Force of Will" in readme - assert "Ponder" in readme - assert "NicolAI Bolas" in readme - - -def main() -> int: - tests = [ - test_release_identity_is_consistent, - test_required_community_and_automation_files_exist, - test_ci_is_fast_and_does_not_require_bulk_sources_or_ollama, - test_release_packaging_uses_git_and_ignores_local_exports, - test_markdown_is_english_and_personal_letter_is_preserved, - ] - for test in tests: - test() - print(f"OK: {test.__name__}") - print(f"Repository health tests: {len(tests)}/{len(tests)}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) +from __future__ import annotations + +from pathlib import Path +import re +import tomllib + +from magicai.versioning import ( + NEXT_BETA_CODENAME, + NEXT_BETA_VERSION, + PACKAGE_FALLBACK_VERSION, + PUBLIC_VERSION, + RELEASE_CHANNEL, + RELEASE_CODENAME, + RELEASE_TAG, + V1_CODENAME, +) + + +ROOT = Path(__file__).resolve().parents[2] + + +def test_release_identity_is_consistent() -> None: + pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) + assert pyproject["project"]["version"] == PACKAGE_FALLBACK_VERSION + assert PUBLIC_VERSION == "0.1.1-beta" + assert RELEASE_TAG == "v0.1.1-beta" + assert RELEASE_CHANNEL == "beta" + assert RELEASE_CODENAME == "Force of Will" + assert NEXT_BETA_VERSION == "0.2.0-beta" + assert NEXT_BETA_CODENAME == "Ponder" + assert V1_CODENAME == "NicolAI Bolas" + + +def test_required_community_and_automation_files_exist() -> None: + required = [ + ".github/workflows/ci.yml", + ".github/dependabot.yml", + ".github/PULL_REQUEST_TEMPLATE.md", + "SECURITY.md", + "CODE_OF_CONDUCT.md", + "SUPPORT.md", + "docs/BRANCHING.md", + "docs/RELEASE_PROCESS.md", + "docs/REPOSITORY_HEALTH.md", + "scripts/ci_check.py", + "scripts/package_release.py", + "scripts/export_github_analysis.sh", + ] + missing = [relative for relative in required if not (ROOT / relative).is_file()] + assert not missing, f"Missing repository-health files: {missing}" + + +def test_ci_is_fast_and_does_not_require_bulk_sources_or_ollama() -> None: + workflow = (ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8") + assert "python scripts/ci_check.py" in workflow + assert "download_sources.sh" not in workflow + assert "oracle_exhaustive_test" not in workflow + assert "ollama pull" not in workflow + assert "permissions:\n contents: read" in workflow + + +def test_release_packaging_uses_git_and_ignores_local_exports() -> None: + script = (ROOT / "scripts/package_release.py").read_text(encoding="utf-8") + gitignore = (ROOT / ".gitignore").read_text(encoding="utf-8") + exporter = (ROOT / "scripts/export_github_analysis.sh").read_text(encoding="utf-8") + + assert '"ls-files", "-z"' in script + assert "FULL_SOURCE_FILES" in script + assert "/github-analysis-*/" in gitignore + assert "/dist/releases/" in gitignore + assert "--slurp" not in exporter + assert "if len(parts) < 6" in exporter + assert '--list-file="$OUT/git/tracked_files.txt"' in exporter + + +def test_markdown_is_english_and_personal_letter_is_preserved() -> None: + markdown_files = sorted( + path + for path in ROOT.rglob("*.md") + if not any(part in {"backups", "github-analysis-20260715-025523"} for part in path.parts) + ) + spanish_markers = re.compile( + r"[¿¡]|\b(?:por motivos|si has llegado|nos vemos|me gustaría|mientras mi salud|gracias por dedicar)\b", + flags=re.IGNORECASE, + ) + violations = [] + for path in markdown_files: + content = path.read_text(encoding="utf-8") + if spanish_markers.search(content): + violations.append(str(path.relative_to(ROOT))) + assert not violations, f"Markdown must remain English-only: {violations}" + + readme = (ROOT / "README.md").read_text(encoding="utf-8") + assert "# ❤️ A personal letter" in readme + assert "Due to health reasons" in readme + assert "See you in the next game." in readme + assert "Force of Will" in readme + assert "Ponder" in readme + assert "NicolAI Bolas" in readme + + +def main() -> int: + tests = [ + test_release_identity_is_consistent, + test_required_community_and_automation_files_exist, + test_ci_is_fast_and_does_not_require_bulk_sources_or_ollama, + test_release_packaging_uses_git_and_ignores_local_exports, + test_markdown_is_english_and_personal_letter_is_preserved, + ] + for test in tests: + test() + print(f"OK: {test.__name__}") + print(f"Repository health tests: {len(tests)}/{len(tests)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/tactician/tactician_autonomous_investigation_test.py b/tests/tactician/tactician_autonomous_investigation_test.py new file mode 100644 index 0000000..d6adbbc --- /dev/null +++ b/tests/tactician/tactician_autonomous_investigation_test.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +from magicai.judge_tools import JudgeToolBudget, JudgeToolResult, JudgeToolStatus +from magicai.tactician.input_analysis import analyze_user_input +from magicai.tactician.investigation import run_investigation +from magicai.tactician.planner import plan_investigation + + +CARDS = [ + { + "name": "Young Wolf", + "oracle_text": "Undying", + "type_line": "Creature — Wolf", + } +] + + +class RecoveringGateway: + def execute(self, request, *, conversation=None, budget=None): + accepted, reason = budget.consume(request) + if not accepted: + return JudgeToolResult( + tool=request.tool, + status=JudgeToolStatus.BUDGET_EXCEEDED, + authority="judge_gateway", + provider="fake", + purpose=request.purpose, + arguments=request.arguments, + error_code=reason, + budget=budget.snapshot(), + ) + + if request.tool == "oracle_lookup": + evidence = [ + { + "kind": "card", + "identifier": "young-wolf-oracle", + "data": dict(CARDS[0]), + } + ] + elif request.tool == "rules_lookup": + evidence = [ + { + "kind": "rule", + "identifier": identifier, + "data": {"number": identifier, "title": f"Rule {identifier}"}, + } + for identifier in request.arguments.get("identifiers", []) + if identifier != "702.93a" + ] + elif request.tool == "rules_search": + evidence = [ + { + "kind": "rule", + "identifier": "702.93a", + "data": {"number": "702.93a", "title": "Undying"}, + } + ] + else: + raise AssertionError(f"unexpected tool: {request.tool}") + + return JudgeToolResult( + tool=request.tool, + status=JudgeToolStatus.SUCCESS, + authority="official_rules", + provider="fake", + purpose=request.purpose, + request_id=request.request_id, + arguments=request.arguments, + evidence=evidence, + budget=budget.snapshot(), + ) + + +def _mechanic_plan(): + analysis = analyze_user_input( + "Morir y entrar al cementerio es lo mismo para Undying, ¿verdad?" + ) + return analysis, plan_investigation(analysis, cards=CARDS) + + +def test_plan_decomposes_claims_into_evidence_hypotheses() -> None: + _, plan = _mechanic_plan() + + assert [item.kind for item in plan.hypotheses] == [ + "oracle_foundation", + "mechanic_equivalence", + "answer_basis", + ] + assert plan.hypotheses[1].required_evidence == ( + "rule:700.4", + "rule:702.93a", + ) + assert plan.hypotheses[1].search_queries + + +def test_investigation_recovers_missing_evidence_with_counterexample_search() -> None: + analysis, plan = _mechanic_plan() + gateway = RecoveringGateway() + + outcome = run_investigation( + analysis=analysis, + requests=plan.requests, + hypotheses=plan.hypotheses, + execute=gateway.execute, + conversation=object(), + budget=JudgeToolBudget(max_calls=4, max_calls_per_tool=2), + ) + + assert outcome.sufficient is True + assert outcome.sufficiency_score == 1.0 + assert outcome.follow_up_queries == 1 + assert outcome.stopped_reason == "evidence_sufficient" + assert [step.phase for step in outcome.steps] == [ + "initial_evidence", + "initial_evidence", + "counterexample_search", + ] + assert outcome.steps[-1].request.tool == "rules_search" + assert outcome.hypotheses[1].resolved_evidence == ( + "rule:700.4", + "rule:702.93a", + ) + + +def test_investigation_stops_when_follow_up_exceeds_budget() -> None: + analysis, plan = _mechanic_plan() + gateway = RecoveringGateway() + + outcome = run_investigation( + analysis=analysis, + requests=plan.requests, + hypotheses=plan.hypotheses, + execute=gateway.execute, + conversation=object(), + budget=JudgeToolBudget(max_calls=2, max_calls_per_tool=2), + ) + + assert outcome.sufficient is False + assert outcome.stopped_reason == "budget_exceeded" + assert outcome.follow_up_queries == 1 + assert outcome.steps[-1].status == "budget_exceeded" + assert outcome.steps[-1].error_code == "maximum_tool_calls_reached" + + +def main() -> int: + test_plan_decomposes_claims_into_evidence_hypotheses() + test_investigation_recovers_missing_evidence_with_counterexample_search() + test_investigation_stops_when_follow_up_exceeds_budget() + print("OK: autonomous investigation planner") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/tactician/tactician_core_test.py b/tests/tactician/tactician_core_test.py index 6e4dc82..ba07d77 100644 --- a/tests/tactician/tactician_core_test.py +++ b/tests/tactician/tactician_core_test.py @@ -106,6 +106,7 @@ def test_tactician_consumes_judge_package_without_direct_sources() -> None: "tactician:language_policy", "tactician:input_analysis", "tactician:claim_evaluation", + "tactician:autonomous_investigation", "tactician:response_orchestration:tactician_led", "tactician:factual_core_preservation", "tactician:answer_contract", @@ -116,6 +117,8 @@ def test_tactician_consumes_judge_package_without_direct_sources() -> None: assert payload["answer_complete"] is True assert payload["response_language"] == "es" assert payload["judge_tool_calls"][0]["status"] == "success" + assert payload["investigation_trace"]["sufficient"] is True + assert payload["investigation_trace"]["sufficiency_score"] == 1.0 assert "sinergia de sacrificio" in payload["answer"] assert "No es un bucle infinito" in payload["answer"] assert conversation.mode == "tactician" diff --git a/tests/tactician/tactician_land_type_investigation_regression_test.py b/tests/tactician/tactician_land_type_investigation_regression_test.py new file mode 100644 index 0000000..a65bd93 --- /dev/null +++ b/tests/tactician/tactician_land_type_investigation_regression_test.py @@ -0,0 +1,249 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from magicai.conversation.models import Conversation +from magicai.judge_tools.models import JudgeToolResult, JudgeToolStatus +from magicai.tactician.core import Tactician +from magicai.tactician.input_analysis import analyze_user_input +from magicai.tactician.planner import plan_investigation +from magicai.retrieval.concept_evidence import mandatory_rule_numbers +from magicai.validation.rule_renderer import render_rule_answer + + +QUESTION = """Estoy jugando Commander. Mi oponente controla Blood Moon. Yo controlo +Urborg, Tomb of Yawgmoth y después lanzo Dryad of the Ilysian Grove. + +¿Qué tipos de tierra, habilidades de maná y colores pueden producir mis tierras +básicas, mis tierras no básicas y las tierras no básicas del oponente? + +¿Cambiaría el resultado si Dryad hubiera entrado antes que Blood Moon? +Explica las capas, dependencias y timestamp aplicables.""" + + +CARDS = [ + { + "name": "Dryad of the Ilysian Grove", + "type_line": "Enchantment Creature — Nymph Dryad", + "oracle_text": "Lands you control are every basic land type in addition to their other types.", + }, + { + "name": "Urborg, Tomb of Yawgmoth", + "type_line": "Legendary Land", + "oracle_text": "Each land is a Swamp in addition to its other land types.", + }, + { + "name": "Blood Moon", + "type_line": "Enchantment", + "oracle_text": "Nonbasic lands are Mountains.", + }, +] + + +RULES = ( + "305.6", + "305.7", + "305.8", + "611.3", + "613.1d", + "613.7", + "613.8a", + "613.8b", +) + + +class InsufficientJudge: + def ask_result(self, conversation, question): + return SimpleNamespace( + to_dict=lambda: { + "schema_version": "1.0", + "question": question, + "answer": "No he podido generar una explicación completa con suficiente seguridad.", + "status": "insufficient_evidence", + "origin": "safe_fallback", + "confidence": "low", + "authority": "judge", + "cards": CARDS, + "rules": [], + "rulings": [], + "retrieval_queries": [], + "assumptions": [], + "warnings": ["The answer looks incomplete."], + "source_versions": {}, + "source_health": {}, + "validation_attempts": 2, + } + ) + + + + +def _deterministic_answer() -> str: + cards_block = "\n\n".join( + f"{card['name']}\n{card['type_line']}\n\n{card['oracle_text']}" + for card in CARDS + ) + rules_block = "\n\n".join( + f"{number}\nRule {number}" + for number in RULES + ) + knowledge = ( + f"QUESTION\n\n{QUESTION}\n\n" + "============================================================\n" + f"CARDS\n\n{cards_block}\n\n" + "============================================================\n" + f"RULES\n\n{rules_block}\n" + ) + answer = render_rule_answer(knowledge) + assert answer is not None + return answer + + +class AnsweredJudge: + def ask_result(self, conversation, question): + answer = _deterministic_answer() + return SimpleNamespace( + to_dict=lambda: { + "schema_version": "1.0", + "question": question, + "answer": answer, + "status": "answered", + "origin": "deterministic_rule", + "confidence": "high", + "authority": "judge", + "cards": CARDS, + "rules": [ + {"number": number, "title": f"Rule {number}"} + for number in RULES + ], + "rulings": [], + "retrieval_queries": list(RULES), + "assumptions": [], + "warnings": [], + "source_versions": {}, + "source_health": {}, + "validation_attempts": 0, + } + ) + + +class CompleteEvidenceGateway: + def execute(self, request, *, conversation=None, budget=None): + if request.tool == "oracle_lookup": + evidence = [ + {"kind": "card", "identifier": card["name"], "data": card} + for card in CARDS + ] + authority = "official_card_data" + elif request.tool == "rules_lookup": + evidence = [ + { + "kind": "rule", + "identifier": identifier, + "data": {"number": identifier, "title": f"Rule {identifier}"}, + } + for identifier in request.arguments.get("identifiers", []) + ] + authority = "comprehensive_rules" + elif request.tool == "rules_search": + evidence = [] + authority = "comprehensive_rules" + else: + raise AssertionError(f"unexpected tool: {request.tool}") + return JudgeToolResult( + tool=request.tool, + status=JudgeToolStatus.SUCCESS, + authority=authority, + provider="fake", + purpose=request.purpose, + arguments=request.arguments, + evidence=evidence, + budget=budget.snapshot() if budget else {}, + ) + + +def test_question_is_classified_as_rules_clarification() -> None: + analysis = analyze_user_input(QUESTION) + + assert analysis.strategy_intent.value == "rules_clarification" + assert { + "layers", + "dependency", + "timestamp", + "land_types", + "basic_lands", + "nonbasic_lands", + "mana_abilities", + }.issubset(set(analysis.concepts)) + + +def test_judge_context_reserves_complete_land_layer_evidence() -> None: + assert mandatory_rule_numbers(QUESTION) == RULES + + +def test_plan_requires_complete_land_layer_evidence() -> None: + analysis = analyze_user_input(QUESTION) + plan = plan_investigation(analysis, cards=CARDS) + rule_requests = [ + request for request in plan.requests + if request.tool == "rules_lookup" + ] + + assert len(rule_requests) == 1 + assert set(RULES).issubset(set(rule_requests[0].arguments["identifiers"])) + required = { + token + for hypothesis in plan.hypotheses + for token in hypothesis.required_evidence + } + assert {f"rule:{number}" for number in RULES}.issubset(required) + + +def test_insufficient_judge_cannot_be_reported_as_verified_or_complete() -> None: + result = Tactician( + judge=InsufficientJudge(), + tool_gateway=CompleteEvidenceGateway(), + ).ask_result(Conversation(), QUESTION) + payload = result.to_dict() + + assert payload["investigation_trace"]["sufficient"] is True + assert payload["judge_result"]["status"] == "insufficient_evidence" + assert payload["response_mode"] == "judge_led" + assert payload["judge_verified"] is False + assert payload["answer_complete"] is False + assert payload["answer_contract"]["answer_complete"] is False + assert payload["factual_core_coverage"]["required"] == 0 + assert "judge:evidence_incomplete" in payload["authority_trace"] + + + +def test_answered_deterministic_judge_has_consistent_metadata() -> None: + result = Tactician( + judge=AnsweredJudge(), + tool_gateway=CompleteEvidenceGateway(), + ).ask_result(Conversation(), QUESTION) + payload = result.to_dict() + + assert payload["judge_result"]["status"] == "answered" + assert payload["judge_result"]["origin"] == "deterministic_rule" + assert payload["investigation_trace"]["sufficient"] is True + assert all(payload["answer_contract"]["checks"].values()) + assert payload["answer_contract"]["answer_complete"] is True + assert payload["answer_complete"] is True + assert payload["judge_verified"] is True + assert payload["confidence"] == "high" + assert "judge:evidence_verification" in payload["authority_trace"] + assert "judge:evidence_incomplete" not in payload["authority_trace"] + +def main() -> int: + test_question_is_classified_as_rules_clarification() + test_judge_context_reserves_complete_land_layer_evidence() + test_plan_requires_complete_land_layer_evidence() + test_insufficient_judge_cannot_be_reported_as_verified_or_complete() + test_answered_deterministic_judge_has_consistent_metadata() + print("OK: land type investigation regression") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/validation/rule_renderer_land_type_layers_test.py b/tests/validation/rule_renderer_land_type_layers_test.py new file mode 100644 index 0000000..0149ef2 --- /dev/null +++ b/tests/validation/rule_renderer_land_type_layers_test.py @@ -0,0 +1,164 @@ +from magicai.validation.rule_renderer import render_rule_answer + + +QUESTION = """Estoy jugando Commander. Mi oponente controla Blood Moon. Yo controlo +Urborg, Tomb of Yawgmoth y después lanzo Dryad of the Ilysian Grove. + +¿Qué tipos de tierra, habilidades de maná y colores pueden producir mis tierras +básicas, mis tierras no básicas y las tierras no básicas del oponente? + +¿Cambiaría el resultado si Dryad of the Ilysian Grove hubiera entrado antes que +Blood Moon? Explica las capas, dependencias y timestamp aplicables.""" + + +KNOWLEDGE = f"""QUESTION + +{QUESTION} + +============================================================ +CARDS + +Dryad of the Ilysian Grove +Enchantment Creature — Nymph Dryad + +You may play an additional land on each of your turns. +Lands you control are every basic land type in addition to their other types. + +Urborg, Tomb of Yawgmoth +Legendary Land + +Each land is a Swamp in addition to its other land types. + +Blood Moon +Enchantment + +Nonbasic lands are Mountains. + +============================================================ +RULES + +305.6 +Basic land types grant intrinsic mana abilities. + +305.7 +Setting a basic land type removes old land types and printed abilities. + +305.8 +Basic is a supertype; nonbasic lands remain nonbasic. + +611.3 +Static abilities generate continuous effects. + +613.1d +Layer 4 type-changing effects. + +613.7 +Timestamp order. + +613.8a +Dependency definition. + +613.8b +Dependent effects wait. +""" + + +def test_land_type_renderer_resolves_dependency_and_timestamp() -> None: + answer = render_rule_answer(KNOWLEDGE) + + assert answer is not None + assert "capa 4" in answer + assert "dependencia prevalece sobre la marca de tiempo" in answer + assert "Urborg, Tomb of Yawgmoth no llega a convertir las tierras en Pantano" in answer + assert "Tus tierras básicas" in answer + assert "pueden producir los cinco colores" in answer + assert "Tus tierras no básicas" in answer + assert "Llanura, Isla, Pantano, Montaña y Bosque" in answer + assert "Las tierras no básicas del oponente" in answer + assert "solo pueden producir maná rojo (R)" in answer + assert "hubiera entrado antes que Blood Moon" in answer + assert "terminarían siendo solo Montaña" in answer + assert "todo se resuelva únicamente por timestamp" in answer + + +def main() -> int: + test_land_type_renderer_resolves_dependency_and_timestamp() + print("OK: land type layer renderer") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) + + +GENERIC_QUESTION = """Estoy jugando Commander. Mi oponente controla Ashen Eclipse. +Yo controlo Tidal Nexus y después lanzo Prismatic Sage. + +¿Qué tipos de tierra y colores pueden producir mis tierras básicas, mis tierras +no básicas y las tierras no básicas del oponente? + +¿Cambiaría si Prismatic Sage hubiera entrado antes que Ashen Eclipse? +Explica capa, dependencia y timestamp.""" + + +GENERIC_KNOWLEDGE = f"""QUESTION + +{GENERIC_QUESTION} + +============================================================ +CARDS + +Prismatic Sage +Enchantment Creature — Sage + +Lands you control are every basic land type in addition to their other types. + +Tidal Nexus +Legendary Land + +Each land is an Island in addition to its other land types. + +Ashen Eclipse +Enchantment + +Nonbasic lands are Swamps. + +============================================================ +RULES + +305.6 +Basic land types grant intrinsic mana abilities. + +305.7 +Setting a basic land type removes old land types and printed abilities. + +305.8 +Basic is a supertype; nonbasic lands remain nonbasic. + +611.3 +Static abilities generate continuous effects. + +613.1d +Layer 4 type-changing effects. + +613.7 +Timestamp order. + +613.8a +Dependency definition. + +613.8b +Dependent effects wait. +""" + + +def test_land_type_renderer_is_oracle_pattern_driven_not_card_name_driven() -> None: + answer = render_rule_answer(GENERIC_KNOWLEDGE) + + assert answer is not None + assert "Tidal Nexus" in answer + assert "Ashen Eclipse" in answer + assert "Prismatic Sage" in answer + assert "convertir las tierras en Isla" in answer + assert "solo Pantano" in answer + assert "maná negro (B)" in answer