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/.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/README.md b/README.md index 4c9ca1d..b466de2 100644 --- a/README.md +++ b/README.md @@ -72,9 +72,16 @@ The current Tactician milestone adds: ```text Automatic strategic handoff Conversation-card inheritance -Intent-specific strategy routing +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 -Judge capability registry Structured combo steps and outcomes ``` diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 8deae4e..dd16633 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -1,57 +1,82 @@ -# 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 -``` +# API contract -## Ask request +Current API contract version: `1.8`. -```json -{ - "question": "And does it combo with Ghave and Ashnod's Altar?", - "session_id": "optional-session-id", - "auto_handoff": true -} -``` +## Stable result families + +- Judge results use JudgeResult schema `1.0`. +- Tactician results use TacticianResult schema `0.7`. +- 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` -`auto_handoff` defaults to `true`. Set it to `false` only for diagnostics that need the raw `strategy_required` Judge boundary. +## Judge Tool Gateway endpoint -## Strategic response fields +`POST /judge/tools/execute` executes one read-only Judge-owned capability. -A handoff response remains compatible with the Judge evidence fields and may add: +Example: ```json { - "authority": "tactician", - "strategy_intent": "combo_detection", - "combo_classification": "infinite_combo", - "combo_steps": [], - "outcomes": [], - "synergies": [], - "risks": [], - "inherited_cards": ["Young Wolf"], - "judge_queries": [], - "judge_result": {} + "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. + +## Tactician reasoning fields + +TacticianResult `0.7` includes: + +- `input_analysis` +- `claim_verdicts` +- `reasoning_summary` +- `queries_planned` +- `queries_completed` +- `judge_verified` +- `investigation_plan` +- `investigation_trace` +- `response_language` +- `language_policy` +- `answer_obligations` +- `answer_contract` +- `answer_complete` +- `response_mode` +- `response_orchestration` +- `factual_core` +- `factual_core_coverage` +- `factual_core_preserved` +- `strategic_extension_required` -## Release metadata +`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. -`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**. +`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/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 68cc730..3e32dd5 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,51 @@ 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 + ├─ 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 ``` -## 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 +89,12 @@ 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. + +## 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 d33b149..e4f1e78 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 ``` @@ -102,3 +105,78 @@ 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 +``` + +## Tactician conversational understanding + +Run the focused orchestration checks: + +```bash +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 contract and CI regression wrappers: + +```bash +python -m tests.quality.tactician_conversation_contract_test +python -m tests.quality.tactician_conversation_regression_test +``` + +Run the same cases against the local MagicAI installation: + +```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/JUDGE_TOOL_GATEWAY.md b/docs/JUDGE_TOOL_GATEWAY.md new file mode 100644 index 0000000..de2bc33 --- /dev/null +++ b/docs/JUDGE_TOOL_GATEWAY.md @@ -0,0 +1,132 @@ +# 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. + +## Tactician planner use + +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. + +`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/ROADMAP.md b/docs/ROADMAP.md index 959e17e..fd6649a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -14,7 +14,7 @@ The next integrated beta requires: - source-grounded validation and safe failure; - automatic Judge-to-Tactician handoff; - resilient conversation continuity; -- iterative Tactician queries through the Judge tool gateway; +- iterative Tactician queries through the Judge Tool Gateway; - formal combo reconstruction; - useful local UI and persistent history; - reproducible installation and evaluation. @@ -40,32 +40,103 @@ The first major release should add mature deck analysis, authorized strategic so - Judge contradiction review. - Basic role and synergy analysis. -### 12.1 — Automatic handoff and continuity — implemented +### 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 Young Wolf + mana outlet + counter converter loop recognition. +- Generic Undying loop recognition. - Judge capability registry. -### 12.2 — Judge tool gateway +### 12.2a — Executable Judge Tool Gateway — complete - Typed tool requests and responses. -- Per-tool provenance, version, latency, and authority. -- Legality exposed to Tactician evidence. -- Query cache and budgets. +- 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. - -### 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. +- Read-only diagnostic REST endpoint. +- Initial Tactician Oracle refresh through the gateway. + +### 12.2b — Input reasoning and conversational orchestration — complete + +- 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 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 — 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 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 sequencing, requirements, and disruption regression. + +### 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 58934a6..1e5dcf5 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,52 +1,55 @@ -# 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.3a2` closes the land-type investigation line with metadata consistency and renderer generalization. + +Completed in this milestone: + +- 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.3b` will generalize adaptive research policies: + +- 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 + +- Casual-language normalization is conservative and currently covers a bounded vocabulary. +- 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. +- A full deck analyzer and collection provider are not yet implemented. diff --git a/docs/TACTICIAN.md b/docs/TACTICIAN.md index 54fc79f..658dc98 100644 --- a/docs/TACTICIAN.md +++ b/docs/TACTICIAN.md @@ -13,23 +13,45 @@ 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.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 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; -- Judge review challenges for contradictory LLM answers; -- Judge capability registry. +- executable, typed Judge Tool Gateway; +- bounded Oracle, rules, and rulings requests; +- speech-act, intent, claim, and question-target analysis; +- persisted strategic conversation context; +- 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 @@ -37,16 +59,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.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 + +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/docs/TACTICIAN_REASONING.md b/docs/TACTICIAN_REASONING.md new file mode 100644 index 0000000..ab50440 --- /dev/null +++ b/docs/TACTICIAN_REASONING.md @@ -0,0 +1,181 @@ +# Tactician input reasoning and response orchestration + +Sprint 12.3a extends the structured reasoning layer with explicit hypotheses, evidence-sufficiency scoring, bounded reactive searches, response ownership, and factual-core preservation. + +## Goal + +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 + +```text +user wording + → shared casual-language normalization + → 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 + → 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. + +Resolution order: + +```text +clear current-turn language + → established session language + → configured default language +``` + +The selected language applies to the answer and all user-visible strategic metadata. + +## 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 answers in a stable professional register. The Tactician may remain warmer and more conversational. + +## Conversation Gauntlet + +The initial corpus is stored under: + +```text +tests/quality/cases/tactician_conversations/ +``` + +It contains 40 scenarios and 58 turns across: + +- 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. + +Fixture mode is fast and deterministic: + +```bash +python -m tests.quality.tactician_conversations.runner \ + --mode fixture \ + --cases tests/quality/cases/tactician_conversations +``` + +Local mode uses the installed MagicAI sources and model: + +```bash +python -m tests.quality.tactician_conversations.runner \ + --mode local \ + --cases tests/quality/cases/tactician_conversations \ + --output-dir quality-results/tactician-conversations-local +``` + +Both modes generate `summary.json` and `report.html`. + +## Feedback promotion + +An exported UI result can be converted into a reviewable candidate: + +```bash +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. 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/routes.py b/magicai/api/routes.py index 5a0dc1c..be4a114 100644 --- a/magicai/api/routes.py +++ b/magicai/api/routes.py @@ -12,12 +12,18 @@ 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 get_capability_registry_payload +from magicai.judge_tools import ( + JudgeToolGateway, + JudgeToolRequest, + get_capability_registry_payload, +) from magicai.judge_result import ( JudgeConfidence, JudgeOrigin, @@ -26,6 +32,7 @@ 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, @@ -41,7 +48,8 @@ router = APIRouter() assistant = MagicAI() -tactician = Tactician(judge=assistant) +judge_tool_gateway = JudgeToolGateway() +tactician = Tactician(judge=assistant, tool_gateway=judge_tool_gateway) conversation_manager = ConversationManager() @@ -82,6 +90,13 @@ def meta(): "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(), + }, } @@ -90,6 +105,31 @@ 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( @@ -107,6 +147,7 @@ def ask(request: AskRequest): question=request.question, judge_result=result, prior_cards=prior_cards, + conversation=conversation, ) replace_boundary_answer(conversation, result) diff --git a/magicai/api/schemas.py b/magicai/api/schemas.py index b70787c..10c5d2b 100644 --- a/magicai/api/schemas.py +++ b/magicai/api/schemas.py @@ -68,6 +68,27 @@ class AskResponse(BaseModel): 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 + 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) + 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) + 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) @@ -94,6 +115,37 @@ class MetaResponse(BaseModel): 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): 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/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/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/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/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/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/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 new file mode 100644 index 0000000..80d9529 --- /dev/null +++ b/magicai/tactician/answer_contract.py @@ -0,0 +1,333 @@ +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_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."), + 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.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."), + 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 == "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": + 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 == "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": + 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", "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 + + +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": + 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 new file mode 100644 index 0000000..98d69fd --- /dev/null +++ b/magicai/tactician/claims.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 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()) + + return [ + _evaluate_claim( + claim, + language=analysis.language, + has_ozolith=has_ozolith, + has_undying=has_undying, + evidence_ids=evidence_ids, + ) + 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.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.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.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.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.claim_id, + claim.text, + ClaimVerdictStatus.INSUFFICIENT_EVIDENCE, + explanation, + (), + ) + + +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 178d102..69f6760 100644 --- a/magicai/tactician/core.py +++ b/magicai/tactician/core.py @@ -4,19 +4,48 @@ from typing import Any from magicai.assistant import MagicAI -from magicai.tactician.intents import ( - classify_strategy_intent, - looks_like_referential_follow_up, +from magicai.judge_tools import ( + JudgeToolBudget, + JudgeToolGateway, + JudgeToolRequest, ) +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, +) +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.investigation import InvestigationOutcome, run_investigation +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.orchestration import ( + ResponseMode, + choose_response_mode, + combo_classification_for_turn, +) +from magicai.tactician.planner import plan_investigation +from magicai.tactician.synthesis import synthesize_strategy 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,10 +58,11 @@ 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) - 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" @@ -44,99 +74,280 @@ 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. + """Investigate the user's input and synthesize a strategic answer. - 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 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, + session_language=getattr(conversation, "language", None), + ) 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", + "rules_clarification", + "mechanic_definition", + "mechanic_resolution", + "mechanic_equivalence", + "combo_failure_explanation", + "interaction_timing", + } + ), + ) + + budget = JudgeToolBudget( + max_calls=8, + max_calls_per_tool=3, + max_repeated_request=1, + max_elapsed_seconds=30.0, + ) + plan = plan_investigation( + input_analysis, + cards=merged_cards, + oracle_already_refreshed=False, + ) + 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}, + tool_calls, + ) + claim_verdicts = evaluate_claims( + input_analysis, + cards=merged_cards, + 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 + obligations = build_answer_obligations( + input_analysis, + has_current_interaction=has_current_interaction, + ) + answer_contract = validate_answer_contract( + final_answer, + analysis=input_analysis, + obligations=obligations, + has_current_interaction=has_current_interaction, + ) + 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_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, + if judge_status not in {"strategy_required", "answered"}: + status = judge_status or "insufficient_evidence" + 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, ) - answer = analysis.answer + else: status = "answered" - origin = "tactician_strategy" - confidence = _strategy_confidence( - analysis.combo_classification, - judge_payload, + warnings = localize_messages( + list(judge_payload.get("warnings", [])), + input_analysis.language, ) - synergies = analysis.synergies - risks = analysis.risks - combo_classification = analysis.combo_classification - combo_steps = analysis.combo_steps - outcomes = analysis.outcomes - else: - answer = str(judge_payload.get("answer", "")) - synergies = [] - risks = [] - combo_classification = "not_applicable" - combo_steps = [] - outcomes = [] - status = judge_status or "insufficient_evidence" - origin = "tactician_judge_gate" - confidence = str(judge_payload.get("confidence", "low")) - 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=combo_classification, + 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, + judge_payload, + judge_verified=judge_verified, + ) + + 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)), + } ) - return TacticianResult( + authority_trace = ["judge:factual_evidence"] + if tool_calls: + authority_trace.append("judge:tool_gateway") + authority_trace.extend( + [ + "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", + "judge:evidence_verification" if judge_verified else "judge:evidence_incomplete", + ] + ) + + result = TacticianResult( question=question, - answer=answer, + answer=final_answer, status=status, - origin=origin, + 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=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, + synergies=synthesis.synergies, + risks=synthesis.risks, combo_classification=combo_classification, - combo_steps=combo_steps, - outcomes=outcomes, + combo_steps=synthesis.combo_steps, + outcomes=synthesis.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=( + 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(), + claim_verdicts=[verdict.to_dict() for verdict in claim_verdicts], + reasoning_summary=synthesis.reasoning_summary, + queries_planned=investigation.initial_queries + investigation.follow_up_queries, + queries_completed=len(tool_calls), + judge_verified=judge_verified, + 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], + 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 + 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 replace_boundary_answer(conversation, result: TacticianResult) -> None: """Replace the Judge boundary message after an automatic handoff.""" @@ -145,19 +356,130 @@ 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.language = result.response_language + 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), + "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), + } -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]], + answer_complete: bool, + factual_core_preserved: bool, + investigation_sufficient: bool, + judge_status: str, +) -> 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 + 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 + return combo_classification in { + "infinite_combo", + "non_combo", + "repeatable_synergy", + "not_applicable", + } 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_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], @@ -184,11 +506,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 +543,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) @@ -218,3 +570,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/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 new file mode 100644 index 0000000..81925bb --- /dev/null +++ b/magicai/tactician/input_analysis.py @@ -0,0 +1,262 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +import re +import unicodedata + +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): + 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" + MECHANIC_EQUIVALENCE = "mechanic_equivalence" + 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 + 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), + "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í", "verdad", "cierto", "is that correct", "does that work", +) + + +def analyze_user_input(text: str, *, session_language: str | None = None) -> InputAnalysis: + raw = (text or "").strip() + 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 re.match(r"^(y|and)\b", normalized): + speech_act = SpeechAct.FOLLOW_UP + 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(canonical) + concepts = _detect_concepts(normalized) + claims = _extract_claims(raw, normalized, concepts, speech_act, intent) + + 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 + + return InputAnalysis( + raw_text=raw, + normalized_text=normalized, + 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], + speech_act: SpeechAct, + intent: StrategyIntent, +) -> 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(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( + "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")): + # 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 and speech_act in {SpeechAct.HYPOTHESIS, SpeechAct.CHALLENGE, SpeechAct.REQUEST}: + add(raw, ClaimKind.STRATEGIC, tuple(concepts)) + + # 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 + add(fragment, ClaimKind.FACTUAL, 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")), + ("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")), + ("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")), + ("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): + concepts.append(concept) + return concepts + + +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: + 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|entonces)\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..b8148f1 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" @@ -11,65 +13,101 @@ 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" + 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" 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", +) +_DISRUPTION_MARKERS = ( + "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", +) +_HYPOTHESIS_MARKERS = ( + "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", "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", +) +_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", ) 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 _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): + 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): + 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): @@ -80,53 +118,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/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 b821bc5..9f7e4d9 100644 --- a/magicai/tactician/models.py +++ b/magicai/tactician/models.py @@ -73,8 +73,29 @@ 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) + 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) + 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) + 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 { @@ -107,6 +128,27 @@ 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), + "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), + "investigation_trace": dict(self.investigation_trace), + "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), + "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 new file mode 100644 index 0000000..a72af1e --- /dev/null +++ b/magicai/tactician/planner.py @@ -0,0 +1,206 @@ +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 +from magicai.tactician.investigation import InvestigationHypothesis, build_hypotheses +from magicai.tactician.intents import StrategyIntent + + +@dataclass(slots=True) +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], + } + + +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")] + 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), + )) + goals.append( + "Verificar el texto Oracle actual de cada carta implicada." + if spanish else + "Verify the current Oracle text for every referenced card." + ) + + rules: list[str] = [] + concepts = set(analysis.concepts) + 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: + 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." + 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( + "Verificar la condición de disparo de Undying." + if spanish else + "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( + "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( + "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), + )) + + # 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." + ) + + 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]: + 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]: + 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..91c6460 --- /dev/null +++ b/magicai/tactician/synthesis.py @@ -0,0 +1,381 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +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 + + +@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], + 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) + + 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): + 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_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], +) -> 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" + 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 = ( + "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: " + + " ".join(f"{index}. {step}" for index, step in enumerate(base_steps_en, start=1)) + + " Each iteration produces one net colorless mana and one Saproling." + ) + 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 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. " + "The Altar sacrifice is a cost and its mana ability does not use the stack, so the main windows are Undying and Ghave." + ) + 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 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 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 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." + ) + 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], +) -> 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.", + ] + answer = ( + 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."] + 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." + steps = [ + "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.", + ] + 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."] + 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, "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]) + + +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 8856f5a..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,10 +852,26 @@ 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(" · ") || "—"], ["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 || "—"], + ["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)], + ["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/validation/rule_renderer.py b/magicai/validation/rule_renderer.py index 8e73e49..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"], @@ -103,6 +107,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"], @@ -138,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, @@ -661,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, @@ -1339,6 +1669,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 c3d09f2..edc775d 100644 --- a/magicai/versioning.py +++ b/magicai/versioning.py @@ -1,8 +1,9 @@ from __future__ import annotations JUDGE_RESULT_SCHEMA_VERSION = "1.0" -API_CONTRACT_VERSION = "1.3" -TACTICIAN_RESULT_SCHEMA_VERSION = "0.2" +JUDGE_TOOL_RESULT_SCHEMA_VERSION = "1.0" +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 5aed8b5..8b43310 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,3 +22,7 @@ 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 old mode 100755 new mode 100644 index 892b14e..bbeaf32 --- a/scripts/ci_check.py +++ b/scripts/ci_check.py @@ -16,6 +16,17 @@ TEST_MODULES = ( "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", + "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", "tests.tactician.tactician_reviewer_test", "tests.tactician.tactician_strategy_test", "tests.tactician.tactician_conversation_handoff_test", @@ -27,9 +38,15 @@ "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.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.validation.rule_renderer_land_type_layers_test", "tests.ui.ui_assets_test", "tests.ui.ui_usability_test", ) diff --git a/scripts/export_github_analysis.sh b/scripts/export_github_analysis.sh old mode 100755 new mode 100644 diff --git a/scripts/package_release.py b/scripts/package_release.py old mode 100755 new mode 100644 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/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/api/tactician_api_contract_test.py b/tests/api/tactician_api_contract_test.py index c2f73aa..d4bdf61 100644 --- a/tests/api/tactician_api_contract_test.py +++ b/tests/api/tactician_api_contract_test.py @@ -52,6 +52,29 @@ def test_tactician_response_remains_judge_evidence_compatible() -> None: 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}, + 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}], + 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() @@ -61,6 +84,16 @@ def test_tactician_response_remains_judge_evidence_compatible() -> None: 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 + 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" + 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/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/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/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/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/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/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/tactician_conversation_contract_test.py b/tests/quality/tactician_conversation_contract_test.py new file mode 100644 index 0000000..bf115bb --- /dev/null +++ b/tests/quality/tactician_conversation_contract_test.py @@ -0,0 +1,34 @@ +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" + + +def test_conversation_case_contract_is_loadable() -> None: + scenarios = load_scenarios(CASES) + 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: 40 scenarios / 58 turns") + 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..03e3a85 --- /dev/null +++ b/tests/quality/tactician_conversation_regression_test.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from pathlib import Path +from tempfile import TemporaryDirectory + +from tests.quality.tactician_conversations.runner import run_gauntlet + + +CASES = Path(__file__).parent / "cases" / "tactician_conversations" + + +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_fixture_conversation_gauntlet_passes() + print("OK: test_fixture_conversation_gauntlet_passes") + print("Tactician conversation regression tests: 40/40 scenarios, 58/58 turns") + 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..960610d --- /dev/null +++ b/tests/quality/tactician_conversations/__init__.py @@ -0,0 +1,4 @@ +from tests.quality.tactician_conversations.evaluator import evaluate_turn +from tests.quality.tactician_conversations.loader import load_scenarios + +__all__ = ["evaluate_turn", "load_scenarios"] diff --git a/tests/quality/tactician_conversations/evaluator.py b/tests/quality/tactician_conversations/evaluator.py new file mode 100644 index 0000000..b95f909 --- /dev/null +++ b/tests/quality/tactician_conversations/evaluator.py @@ -0,0 +1,69 @@ +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] = [] + for field in ( + "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.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 new file mode 100644 index 0000000..d37ae2e --- /dev/null +++ b/tests/quality/tactician_conversations/loader.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import json +from pathlib import Path + + +def load_scenarios(path: str | Path) -> list[dict]: + 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_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 f1e5438..ba07d77 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,54 @@ 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.", + }, + } + 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=authority, + provider="fake", + purpose=request.purpose, + arguments=request.arguments, + evidence=evidence, + ) + + 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 +102,23 @@ def test_tactician_consumes_judge_package_without_direct_sources() -> None: assert payload["judge_result"]["authority"] == "judge" assert payload["authority_trace"] == [ "judge:factual_evidence", - "tactician:strategic_interpretation", - "judge:source_gateway", + "judge:tool_gateway", + "tactician:language_policy", + "tactician:input_analysis", + "tactician:claim_evaluation", + "tactician:autonomous_investigation", + "tactician:response_orchestration:tactician_led", + "tactician:factual_core_preservation", + "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 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_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..c9c16e8 --- /dev/null +++ b/tests/tactician/tactician_input_reasoning_test.py @@ -0,0 +1,173 @@ +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["response_mode"] == "tactician_led" + 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_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" + 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_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/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()) diff --git a/tests/tactician/tactician_tool_gateway_test.py b/tests/tactician/tactician_tool_gateway_test.py new file mode 100644 index 0000000..c4e0975 --- /dev/null +++ b/tests/tactician/tactician_tool_gateway_test.py @@ -0,0 +1,99 @@ +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): + if request.tool == "oracle_lookup": + 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.)", + }, + } + ] + 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, + ) + + +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()) diff --git a/tests/ui/ui_usability_test.py b/tests/ui/ui_usability_test.py index 804f4f7..bb9817f 100644 --- a/tests/ui/ui_usability_test.py +++ b/tests/ui/ui_usability_test.py @@ -135,6 +135,12 @@ def test_strategy_handoff_renders_tactician_result_and_combo_trace() -> None: 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 '["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 assert ".strategy-summary" in stylesheet 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()) 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 diff --git a/uv.lock b/uv.lock index b3dc345..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.2" -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/cd/95/d3f0ae10836324a2eab98a52b61210ac609f08200bf4bb0dc8132d32f78a/fastapi-0.139.2.tar.gz", hash = "sha256:333145a6891e9b5b3cfceb69baf817e8240cde4d4588ae5a10bf56ffacb6255e", size = 423428, upload-time = "2026-07-16T15:06:17.912Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/c7/cb03251d9dfb177246a9809a76f189d21df32dbd4a845951881d11323b7f/fastapi-0.139.2-py3-none-any.whl", hash = "sha256:b9ad015a835173d59865e2f5d8296fbc2b317bf56a2ba1a5bfbdd03de2fd4b1c", size = 130234, upload-time = "2026-07-16T15:06:19.557Z" }, -] - -[[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.2,<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" }, +]