diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a7c1427 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,193 @@ +# Contributing to Delibera + +Thank you for your interest in contributing to Delibera. This document provides guidelines for contributing safely and effectively. + +## Repository Structure + +``` +delibera/ +├── src/delibera/ # Main source code +│ ├── engine/ # Orchestrator and operators +│ ├── protocol/ # Protocol spec and loader +│ ├── agents/ # Agent stubs (proposer, researcher, etc.) +│ ├── epistemics/ # Claims, evidence, ledger, validation +│ ├── tools/ # Tool registry, router, policy +│ ├── gates/ # User gate system +│ ├── trace/ # Tracing, replay, validation +│ ├── scoring/ # Scoring metrics and weights +│ ├── inspect/ # Run inspection and reporting +│ ├── eval/ # Evaluation harness +│ └── cli.py # CLI entry point +├── tests/ # Test suite +├── docs/ # Documentation +├── protocols/ # Example protocol YAML files +└── evidence/ # Default evidence pack +``` + +## Invariants You Must Respect + +These invariants are **non-negotiable**. PRs violating them will be rejected. + +### 1. Engine-Only Control + +The engine alone controls: +- Tree structure (expand, prune, reduce) +- Run termination (convergence) +- Operator sequencing + +Agents **must not**: +- Mutate tree state +- Call operators directly +- Bypass policy checks + +### 2. Replay Must Be Read-Only + +Replay reconstructs runs from traces without: +- Calling agents +- Calling tools +- Modifying any state + +If your change breaks replay determinism, it's a bug. + +### 3. Tracing Must Be Complete + +Every significant action must emit a trace event: +- All operator applications +- All agent outputs +- All tool calls (requested, executed, denied) +- All gate interactions + +If something happens but isn't traced, it's invisible to audit and replay. + +### 4. No Network Tools + +Delibera currently supports only local tools. Do not add: +- HTTP clients +- API integrations +- External service calls + +This may change in future versions, but not without explicit design work. + +## How to Add Things + +### Adding a New Agent Stub + +1. Create a new class in `src/delibera/agents/stub.py` or a new file +2. Implement the agent interface (see existing stubs) +3. Register it in the appropriate factory +4. Add tests proving it produces valid output +5. Ensure it doesn't call operators or mutate state + +### Adding a New Tool + +1. Create a tool class implementing `ToolSpec` protocol +2. Define `name`, `risk_level`, `capability`, and `execute()` +3. Register in `create_default_registry()` if built-in +4. Add policy rules if needed +5. Add tests for validation and execution + +### Adding a New Protocol + +1. Create a YAML file in `protocols/` +2. Follow the schema (see `tree_v1.yaml` as reference) +3. Test with `delibera run --protocol your_protocol.yaml` +4. Ensure all steps have corresponding agent implementations + +### Adding a New Gate Type + +1. Define the gate type in `gates/models.py` +2. Implement predicate in `gates/predicates.py` +3. Add handler support in `gates/handler.py` +4. Add tests for trigger and response handling + +## Running Tests + +```bash +# Run all tests +uv run pytest + +# Run with verbose output +uv run pytest -v + +# Run specific test file +uv run pytest tests/test_v0_run.py + +# Run with coverage +uv run pytest --cov=delibera +``` + +## Code Quality + +```bash +# Type checking (required to pass) +uv run mypy src/ + +# Linting (required to pass) +uv run ruff check src/ tests/ + +# Auto-fix lint issues +uv run ruff check --fix src/ tests/ +``` + +## PR Requirements + +Before submitting a PR: + +1. **Tests required**: Add tests for new functionality +2. **Tests pass**: `uv run pytest` must pass +3. **Types pass**: `uv run mypy src/` must pass +4. **Lint passes**: `uv run ruff check src/ tests/` must pass +5. **Determinism**: New code must not introduce non-determinism +6. **No behavior changes**: Unless explicitly intended, PRs should not change deliberation semantics + +## Determinism Guidelines + +Delibera requires deterministic behavior for replay. Avoid: + +- `dict` iteration order assumptions (use `sorted()`) +- Timestamp-based sorting without stable tiebreakers +- Random number generation without seeding +- File system order assumptions + +Good pattern: +```python +# Sort by stable key +items = sorted(items, key=lambda x: (x.score, x.id)) +``` + +Bad pattern: +```python +# Non-deterministic order +for key in some_dict: + process(key) +``` + +## Commit Messages + +Use clear, descriptive commit messages: + +``` +Feat: add tradeoff gate for close-score decisions + +- Add predicate checking score difference +- Add gate handler for weight selection +- Add tests for trigger and response +``` + +Prefixes: +- `Feat:` — New feature +- `Fix:` — Bug fix +- `Refactor:` — Code restructuring +- `Docs:` — Documentation only +- `Test:` — Test-only changes +- `Chore:` — Maintenance tasks + +## Getting Help + +- Read the [docs/](docs/README.md) for architecture and design +- Check existing tests for usage examples +- Open an issue for design questions before large PRs + +## License + +By contributing, you agree that your contributions will be licensed under the same license as the project. diff --git a/README.md b/README.md index 8506b90..1c00977 100644 --- a/README.md +++ b/README.md @@ -2,95 +2,183 @@ An engine for **decision-grade AI deliberation**. -## What is Delibera? +Delibera is an open-source framework that makes multi-agent reasoning **structured, governed, and auditable**. Unlike chat-based AI systems, Delibera treats deliberation as a process—not a conversation. The engine controls tree expansion, pruning, and convergence while agents contribute content. Every run can be replayed without re-invoking LLMs. -Delibera is an open-source framework that makes multi-agent reasoning **structured, governed, and auditable**. Unlike chat-based AI systems, Delibera treats deliberation as a process—not a conversation. +## Key Concepts -**Key features:** +| Concept | Description | +|---------|-------------| +| **Engine** | Central orchestrator that controls tree structure and termination. Agents propose; the engine decides. | +| **Protocol** | Declarative YAML spec defining expansion rules, branch pipelines, pruning, and convergence criteria. | +| **Epistemics** | Explicit tracking of claims, evidence, and objections. Fact claims require evidence; inferences must follow from supported facts. | +| **Gates** | Structured human-in-the-loop checkpoints for scope clarification, tradeoffs, and final approval. | +| **Replay** | Full runs can be reconstructed from trace logs without calling agents or tools. | -- **Governed tree search** — Deliberation is modeled as a tree with explicit expansion, pruning, and reduction operators controlled by the engine (not agents) -- **Epistemic tracking** — Claims, evidence, and objections are explicit objects that are validated and auditable -- **Deterministic convergence** — Runs terminate based on measurable predicates, not "when it feels done" -- **Full traceability** — Every run can be replayed without re-invoking LLMs or tools -- **Policy-controlled tools** — All external tool access is governed by layered policies +## Quick Start -**Use cases:** Research synthesis, architecture review, due diligence, strategy analysis, risk assessment. +### Installation -For detailed documentation, see [docs/index.md](docs/index.md). - -## Development +```bash +# Install from PyPI +pip install delibera -This project uses [uv](https://docs.astral.sh/uv/) for dependency management and requires Python 3.12 or later. +# Or clone and install with uv +git clone https://github.com/forge-labs-dev/delibera.git +cd delibera +uv sync +``` -### Setup +### Run a Deliberation ```bash -# Install dependencies -uv sync +# Basic run with interactive gates (uses stub agents) +delibera run --question "Should we adopt uv for dependency management?" -# Install pre-commit hooks -uv run pre-commit install +# Run with auto-approved gates (for CI/scripts) +delibera run --question "Should we adopt uv?" --auto-approve-gates + +# Run with a custom protocol +delibera run --question "Your question" --protocol protocols/tree_v1.yaml ``` -### Running Tests +### Run with LLM-Backed Proposer + +To use an LLM for generating proposals (instead of deterministic stubs): ```bash -# Run tests -uv run pytest +# Set your Gemini API key +export GEMINI_API_KEY="your-api-key" + +# Run with LLM proposer +delibera run --question "Should we adopt uv?" --use-llm-proposer --auto-approve-gates + +# Specify model and parameters +delibera run --question "Your question" \ + --use-llm-proposer \ + --llm-model gemini-1.5-pro \ + --llm-temperature 0.3 \ + --auto-approve-gates +``` -# Run tests with coverage -uv run pytest --cov=delibera +**Note:** LLM mode requires the `google-generativeai` package. Install with: +```bash +pip install delibera[llm] ``` -### Code Quality +Replay and inspection work identically whether the run used LLM or stubs - replay never re-invokes the LLM. + +### Inspect a Run ```bash -# Run linter -uv run ruff check . +# Print a human-readable summary +delibera inspect --run-id -# Auto-fix linting issues -uv run ruff check --fix . +# Generate a Markdown report +delibera report --run-id --out report.md +``` -# Format code -uv run ruff format . +### Replay a Run -# Type checking -uv run mypy src +```bash +# Validate trace and artifact consistency +delibera replay --run-id +``` + +### Run Evaluation Suites + +```bash +# Run an evaluation suite +delibera eval --suite suites/basic.yaml -# Run all pre-commit hooks -uv run pre-commit run --all-files +# Save results to JSON +delibera eval --suite suites/basic.yaml --save-results results.json ``` -## Publishing +## Example Protocol + +```yaml +name: simple_protocol +protocol_version: v1 +max_depth: 1 +gates_enabled: true + +expand_rules: + - id: expand_options + at_step_id: plan + child_kind: option + max_children: 3 + depth: 1 + +branch_pipeline: + - id: propose + kind: work + step_name: PROPOSE + role: proposer + + - id: research + kind: work + step_name: RESEARCH + role: researcher + + - id: validate + kind: validate + step_name: CLAIM_CHECK + +prune: + rule: epistemic_then_score + keep_k: 2 + +reduce: + rule: merge_artifacts + +convergence: + max_rounds: 0 +``` -This project uses GitHub Actions for automated releases to PyPI using trusted publishing. +## What Delibera Is Not -### Setup Trusted Publishing +- **Not an agent framework** — Delibera is not LangChain, CrewAI, or AutoGPT. It's a deliberation engine with strict governance. +- **Not a workflow orchestrator** — Delibera is not Airflow or Prefect. It's specifically for reasoning processes that require epistemic tracking. +- **Not autonomous** — Delibera does not "decide for you". It produces structured decision artifacts for humans to review. +- **Not a chatbot** — Outputs are artifacts, not conversations. -Before creating a release, you need to configure trusted publishers on PyPI: +## Documentation -1. Go to https://pypi.org/manage/account/publishing/ -2. Add a new pending publisher with: - - PyPI Project Name: `delibera` - - Owner: `forge-labs-dev` - - Repository name: `delibera` - - Workflow name: `publish.yml` - - Environment name: `pypi` +See the [docs/](docs/README.md) directory for detailed documentation: -3. For TestPyPI, go to https://test.pypi.org/manage/account/publishing/ and repeat with environment name `testpypi` +- [Vision](docs/vision.md) — Why Delibera exists +- [Architecture](docs/architecture.md) — System structure and invariants +- [Formalism](docs/formalism.md) — Formal model and terminology +- [Protocols](docs/protocol.md) — Protocol specification +- [Epistemics](docs/epistemics.md) — Claims, evidence, and validation +- [Tooling and Policy](docs/tooling-and-policy.md) — Tool access governance +- [User Gates](docs/user-gates.md) — Human-in-the-loop checkpoints +- [Tracing and Replay](docs/tracing-and-replay.md) — Audit and replay -### Creating a Release +## Development ```bash -# Tag a new version -git tag v0.1.0 -git push origin v0.1.0 +# Install dev dependencies +uv sync + +# Run tests +uv run pytest + +# Type checking +uv run mypy src/ + +# Linting +uv run ruff check src/ tests/ ``` -The GitHub Actions workflow will automatically: -1. Build the distribution packages -2. Publish to TestPyPI -3. Publish to PyPI (requires manual approval in GitHub) +See [CONTRIBUTING.md](CONTRIBUTING.md) for contributor guidelines. + +## Roadmap + +- **v0.1** (current) — Core engine, protocols, epistemics, replay, evaluation +- **v0.2** — Strata integration for artifact persistence and lineage tracking +- **v0.3** — Remote tool execution with policy sandboxing +- **v1.0** — Production hardening, performance optimization ## License diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..ef6a0d2 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,65 @@ +# Delibera Documentation + +This directory contains the design documentation for Delibera, an engine for decision-grade AI deliberation. + +## How to Read These Docs + +**If you want to understand what Delibera is:** +Start with [Vision](vision.md). It explains the problem, the approach, and the design principles. + +**If you want to understand how it works:** +Read [Architecture](architecture.md) for system structure, then [Formalism](formalism.md) for the formal model. + +**If you want to use Delibera:** +- [Protocols](protocol.md) — How to define deliberation flows +- [User Gates](user-gates.md) — How to add human checkpoints +- [Tracing and Replay](tracing-and-replay.md) — How to inspect and audit runs + +**If you want to extend Delibera:** +- [Tooling and Policy](tooling-and-policy.md) — How to add tools +- [Epistemics](epistemics.md) — How claims and evidence work + +## Document Index + +| Document | Purpose | When to Read | +|----------|---------|--------------| +| [Vision](vision.md) | Why Delibera exists | First | +| [Formalism](formalism.md) | Formal model, definitions, invariants | Reference | +| [Architecture](architecture.md) | System structure, module boundaries | Understanding internals | +| [Protocols](protocol.md) | Protocol YAML specification | Defining workflows | +| [Epistemics](epistemics.md) | Claims, evidence, objections | Understanding validation | +| [Tooling and Policy](tooling-and-policy.md) | Tool integration, policy layers | Adding tools | +| [User Gates](user-gates.md) | Human-in-the-loop checkpoints | Adding interaction | +| [Tracing and Replay](tracing-and-replay.md) | Trace format, replay semantics | Debugging, auditing | +| [Glossary](glossary.md) | Term definitions | Quick lookup | + +## Recommended Reading Order + +For a complete understanding, read in this order: + +1. **[Vision](vision.md)** — The "why" +2. **[Formalism](formalism.md)** — The canonical model +3. **[Architecture](architecture.md)** — The implementation structure +4. **[Protocols](protocol.md)** — Workflow specification +5. **[Epistemics](epistemics.md)** — Knowledge tracking +6. **[Tooling and Policy](tooling-and-policy.md)** — External capabilities +7. **[Tracing and Replay](tracing-and-replay.md)** — Auditability +8. **[User Gates](user-gates.md)** — Human interaction + +## Key Invariants + +These invariants are enforced throughout the system: + +1. **Engine-only control** — Only the engine modifies tree structure and determines termination +2. **Explicit epistemics** — Claims, evidence, and objections are first-class objects +3. **Deterministic convergence** — Runs stop based on measurable predicates +4. **Complete tracing** — Every action is recorded for replay +5. **Policy-governed tools** — All external access is controlled + +Violating these invariants is a correctness bug. + +## See Also + +- [README.md](../README.md) — Project overview and quick start +- [CONTRIBUTING.md](../CONTRIBUTING.md) — Contributor guidelines +- [protocols/](../protocols/) — Example protocol files diff --git a/protocols/tree_v1_refine.yaml b/protocols/tree_v1_refine.yaml new file mode 100644 index 0000000..2969ed2 --- /dev/null +++ b/protocols/tree_v1_refine.yaml @@ -0,0 +1,72 @@ +# Delibera Tree Protocol v1 with Refinement +# +# This protocol extends tree_v1 with a bounded refinement loop that +# iteratively improves the merged artifact until convergence predicates +# are satisfied or max_rounds is reached. + +name: tree_protocol_v1_refine +protocol_version: v1 +max_depth: 1 +gates_enabled: true + +# Expansion rules define where and how the tree grows +expand_rules: + - id: expand_options + at_step_id: plan + child_kind: option + max_children: 3 + depth: 1 + source: planner_output + +# Branch pipeline: steps executed for each node after expansion +branch_pipeline: + - id: propose + kind: work + step_name: PROPOSE + role: proposer + + - id: research + kind: work + step_name: RESEARCH + role: researcher + + - id: validate + kind: validate + step_name: CLAIM_CHECK + + - id: redteam + kind: work + step_name: REDTEAM + role: redteam + +# Refinement loop: steps executed iteratively on merged artifact +# Each round executes: REFINE -> VALIDATE until convergence or max_rounds +refine_loop: + - id: refine + kind: work + step_name: REFINE + role: refiner + + - id: revalidate + kind: validate + step_name: CLAIM_CHECK + +# Pruning configuration +prune: + rule: epistemic_then_score + keep_k: 2 + +# Reduction configuration +reduce: + rule: merge_artifacts + +# Convergence settings +# Refinement continues until ALL predicates are satisfied OR max_rounds reached: +# 1. no_blocking_objections: No open blocking objections remain +# 2. unsupported_claims == 0: All claims are supported or weak +# 3. weak_claims <= weak_threshold: Number of weak claims is acceptable +# 4. score_improvement < score_epsilon: Score delta is minimal (converged) +convergence: + max_rounds: 3 # Maximum refinement iterations + weak_threshold: 5 # Maximum acceptable weak claims for convergence + score_epsilon: 0.01 # Minimum score improvement to continue refining diff --git a/pyproject.toml b/pyproject.toml index 826f6ca..4e8182a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,11 @@ classifiers = [ "Programming Language :: Python :: 3.13", ] +[project.optional-dependencies] +llm = [ + "google-generativeai>=0.8.0", +] + [project.urls] Homepage = "https://github.com/forge-labs-dev/delibera" Repository = "https://github.com/forge-labs-dev/delibera" @@ -80,3 +85,7 @@ show_error_codes = true [[tool.mypy.overrides]] module = "tests.*" ignore_errors = true + +[[tool.mypy.overrides]] +module = ["google.*", "google.generativeai"] +ignore_missing_imports = true diff --git a/src/delibera/__init__.py b/src/delibera/__init__.py index d537744..f57730e 100644 --- a/src/delibera/__init__.py +++ b/src/delibera/__init__.py @@ -1,5 +1,6 @@ """Delibera - An engine for decision-grade AI deliberation.""" +from delibera import __version__ from delibera.engine.orchestrator import Engine from delibera.engine.state import RunState from delibera.engine.tree import DeliberationTree, Node diff --git a/src/delibera/__version__.py b/src/delibera/__version__.py new file mode 100644 index 0000000..88b7629 --- /dev/null +++ b/src/delibera/__version__.py @@ -0,0 +1,3 @@ +"""Version information for Delibera.""" + +__version__ = "0.1.0" diff --git a/src/delibera/agents/__init__.py b/src/delibera/agents/__init__.py index edef29c..7713ed8 100644 --- a/src/delibera/agents/__init__.py +++ b/src/delibera/agents/__init__.py @@ -1,6 +1,17 @@ """Agent subsystem for Delibera.""" from delibera.agents.base import Agent +from delibera.agents.llm_proposer import LLMNotAllowedError, ProposerLLM, check_llm_allowed_in_step from delibera.agents.stub import PlannerStub, ProposerStub, ResearcherStub, SubplannerStub -__all__ = ["Agent", "PlannerStub", "ProposerStub", "ResearcherStub", "SubplannerStub"] +__all__ = [ + "Agent", + "PlannerStub", + "ProposerStub", + "ResearcherStub", + "SubplannerStub", + # LLM agents + "ProposerLLM", + "LLMNotAllowedError", + "check_llm_allowed_in_step", +] diff --git a/src/delibera/agents/llm_proposer.py b/src/delibera/agents/llm_proposer.py new file mode 100644 index 0000000..bd5faf2 --- /dev/null +++ b/src/delibera/agents/llm_proposer.py @@ -0,0 +1,217 @@ +"""LLM-backed Proposer agent for Delibera. + +Uses an LLM to generate structured proposals with recommendations, +rationale, claims, and confidence scores. +""" + +from typing import TYPE_CHECKING, Any + +from delibera.llm.base import LLMMessage, LLMRequest +from delibera.llm.prompts import build_proposer_system_prompt, build_proposer_user_prompt + +if TYPE_CHECKING: + from delibera.llm.base import LLMClient + from delibera.tools import ToolCallback + + +class ProposerLLM: + """LLM-backed proposer agent. + + Generates proposals using an LLM with structured JSON output. + + Attributes: + llm_client: The LLM client to use for generation. + model: Model name to use (None uses client default). + temperature: Sampling temperature (default 0.2 for consistency). + max_output_tokens: Maximum output tokens (default 800). + """ + + def __init__( + self, + llm_client: "LLMClient", + model: str | None = None, + temperature: float = 0.2, + max_output_tokens: int = 800, + ) -> None: + """Initialize the LLM proposer. + + Args: + llm_client: The LLM client for generation. + model: Model name (None uses client default). + temperature: Sampling temperature. + max_output_tokens: Maximum output tokens. + """ + self._llm_client = llm_client + self._model = model or "" + self._temperature = temperature + self._max_output_tokens = max_output_tokens + + def execute( + self, + context: dict[str, Any], + tool: "ToolCallback | None" = None, # noqa: ARG002 - unused but kept for interface + ) -> dict[str, Any]: + """Generate a proposal for a branch using LLM. + + Args: + context: Must contain: + - "label": Branch label + - "question": Original question + - "node_id": Node ID for tracing + Optional: + - "evidence_snippets": List of evidence excerpts + - "constraints": List of user constraints + tool: Optional tool callback (not used by LLM proposer). + + Returns: + Dict with proposal details matching ProposerStub output format. + """ + label = context.get("label", "") + question = context.get("question", "") + node_id = context.get("node_id", "") + evidence_snippets = context.get("evidence_snippets", []) + constraints = context.get("constraints", []) + + # Build messages + system_prompt = build_proposer_system_prompt() + user_prompt = build_proposer_user_prompt( + question=question, + option_label=label, + evidence_snippets=evidence_snippets, + constraints=constraints, + ) + + messages = [ + LLMMessage(role="system", content=system_prompt), + LLMMessage(role="user", content=user_prompt), + ] + + # Build request with metadata for tracing + request = LLMRequest( + model=self._model, + messages=messages, + temperature=self._temperature, + max_output_tokens=self._max_output_tokens, + response_format="json", + metadata={ + "node_id": node_id, + "role": "proposer", + "step": "PROPOSE", + }, + ) + + # Generate response + response = self._llm_client.generate(request) + + # Parse and normalize the response + parsed = response.parsed_json or {} + return self._normalize_output(parsed, label) + + def _normalize_output( + self, + parsed: dict[str, Any], + label: str, + ) -> dict[str, Any]: + """Normalize LLM output to match expected format. + + Applies deterministic post-processing: + - Strips whitespace + - Drops empty entries + - Truncates long strings + - Clamps confidence to [0, 1] + + Args: + parsed: The parsed JSON from LLM. + label: The option label for fallback. + + Returns: + Normalized output dict. + """ + # Extract and normalize recommendation + recommendation = str(parsed.get("recommendation", "")).strip() + if not recommendation: + recommendation = f"Recommendation for {label}" + + # Extract and normalize rationale (max 6) + rationale_raw = parsed.get("rationale", []) + if not isinstance(rationale_raw, list): + rationale_raw = [str(rationale_raw)] if rationale_raw else [] + rationale = [] + for item in rationale_raw[:6]: + text = str(item).strip() + if text: + # Truncate long rationale items + if len(text) > 300: + text = text[:297] + "..." + rationale.append(text) + + # Extract and normalize claims (max 8) + claims_raw = parsed.get("claims", []) + if not isinstance(claims_raw, list): + claims_raw = [] + claims = [] + valid_types = {"fact", "inference", "plan", "value"} + for item in claims_raw[:8]: + if not isinstance(item, dict): + continue + claim_type = str(item.get("type", "inference")).lower() + if claim_type not in valid_types: + claim_type = "inference" + claim_text = str(item.get("text", "")).strip() + if claim_text: + # Truncate long claims + if len(claim_text) > 200: + claim_text = claim_text[:197] + "..." + claims.append({"type": claim_type, "text": claim_text}) + + # Extract and clamp confidence + confidence_raw = parsed.get("confidence", 0.5) + try: + confidence = float(confidence_raw) + except (TypeError, ValueError): + confidence = 0.5 + confidence = max(0.0, min(1.0, confidence)) + + # Build output in format compatible with ProposerStub + # Extract facts for the "facts" field expected by engine + fact_texts = [c["text"] for c in claims if c["type"] == "fact"] + + return { + "proposal": recommendation, + "summary": rationale[0] if rationale else f"Proposal for {label}", + "pros": rationale[:3], # First 3 rationale items as pros + "cons": rationale[3:5] if len(rationale) > 3 else [], # Next as cons + "facts": fact_texts, + "score": confidence, # Use confidence as initial score + "role": "proposer", + # LLM-specific fields + "recommendation": recommendation, + "rationale": rationale, + "claims": claims, + "confidence": confidence, + "llm_generated": True, + } + + +class LLMNotAllowedError(Exception): + """Raised when LLM is used in a step where it's not allowed.""" + + pass + + +def check_llm_allowed_in_step(step_kind: str) -> None: + """Check if LLM usage is allowed in the given step. + + LLMs are only allowed in WORK steps, never in validate steps. + + Args: + step_kind: The step kind ("work", "validate", "score"). + + Raises: + LLMNotAllowedError: If LLM is not allowed in this step. + """ + if step_kind != "work": + raise LLMNotAllowedError( + f"LLM usage is not allowed in '{step_kind}' steps. " + "LLMs can only be used in 'work' steps." + ) diff --git a/src/delibera/agents/stub.py b/src/delibera/agents/stub.py index 3f8eb66..8436544 100644 --- a/src/delibera/agents/stub.py +++ b/src/delibera/agents/stub.py @@ -165,10 +165,10 @@ def execute(self, context: dict[str, Any]) -> dict[str, Any]: class ResearcherStub: - """Stub researcher that collects evidence via docs.read tool. + """Stub researcher that collects evidence via docs.search + docs.read tools. - Deterministically reads from evidence/uv_notes.txt and extracts - an excerpt based on the option label. + Uses docs.search to discover relevant files within the evidence pack, + then docs.read to extract content from top results. Evidence is returned in a structured format for the engine to merge into the node ledger. @@ -181,8 +181,8 @@ class ResearcherStub: "C": "reproducible", # Will find "reproducible uv.lock" } - # Default evidence file - _EVIDENCE_FILE = "evidence/uv_notes.txt" + # Maximum number of search results to read + _MAX_READ_RESULTS = 2 def execute( self, @@ -191,14 +191,17 @@ def execute( ) -> dict[str, Any]: """Collect evidence for a branch. + Uses docs.search to find relevant files, then docs.read to get content. + Args: context: Must contain "label" key with branch label. - tool: Tool callback for docs.read. + tool: Tool callback for docs.search and docs.read. Returns: Dict with evidence items and notes. """ label = context.get("label", "") + question = context.get("question", "") # Extract option letter option_letter = "A" # default @@ -207,39 +210,78 @@ def execute( option_letter = letter break - # Get search term for this option - search_term = self._SEARCH_TERMS.get(option_letter, "fast") + # Build search query from option-specific term + first keywords from label/question + base_term = self._SEARCH_TERMS.get(option_letter, "fast") + search_query = self._build_search_query(base_term, label, question) evidence_items: list[dict[str, Any]] = [] notes: list[str] = [] if tool is not None: + # Step 1: Search for relevant files try: - # Read the evidence file - result = tool("docs.read", {"path": self._EVIDENCE_FILE}) - text = result.get("text", "") - - # Extract an excerpt containing the search term - excerpt = self._extract_excerpt(text, search_term) - - if excerpt: - evidence_items.append( - { - "source": self._EVIDENCE_FILE, - "excerpt": excerpt, - } - ) - notes.append(f"Found evidence for '{search_term}' in {self._EVIDENCE_FILE}") - else: - notes.append(f"No excerpt found for '{search_term}'") - - except (KeyError, ValueError, OSError, ToolDenied, ToolExecutionError) as e: - # KeyError: tool not registered - # ValueError: invalid input - # OSError: file read error - # ToolDenied: policy denied tool access - # ToolExecutionError: tool execution failed - notes.append(f"Failed to read evidence: {e}") + search_result = tool( + "docs.search", + {"query": search_query, "max_results": self._MAX_READ_RESULTS}, + ) + search_hits = search_result.get("results", []) + notes.append(f"docs.search found {len(search_hits)} results for '{search_query}'") + + except (KeyError, ToolDenied, ToolExecutionError) as e: + # docs.search not available, fall back to direct read + notes.append(f"docs.search failed ({e}), falling back to direct read") + search_hits = [] + + # Step 2: Read top results and extract evidence + if search_hits: + for hit in search_hits[: self._MAX_READ_RESULTS]: + path = hit.get("path", "") + snippet = hit.get("snippet", "") + + if not path: + continue + + try: + read_result = tool("docs.read", {"path": path}) + text = read_result.get("text", "") + + # Extract excerpt around the search term + excerpt = self._extract_excerpt(text, base_term) + if not excerpt and snippet: + # Use snippet from search if no excerpt found + excerpt = snippet + + if excerpt: + evidence_items.append( + { + "source": path, + "excerpt": excerpt, + } + ) + notes.append(f"Found evidence in {path}") + + except (KeyError, ToolDenied, ToolExecutionError) as e: + notes.append(f"Failed to read {path}: {e}") + + # Fallback: if no search hits, try reading a default file + if not search_hits and not evidence_items: + fallback_path = "uv_notes.txt" + try: + read_result = tool("docs.read", {"path": fallback_path}) + text = read_result.get("text", "") + + excerpt = self._extract_excerpt(text, base_term) + if excerpt: + evidence_items.append( + { + "source": fallback_path, + "excerpt": excerpt, + } + ) + notes.append(f"Fallback: found evidence in {fallback_path}") + + except (KeyError, ToolDenied, ToolExecutionError) as e: + notes.append(f"Fallback read failed: {e}") else: notes.append("No tool callback provided; skipping evidence collection") @@ -250,6 +292,31 @@ def execute( "step": "RESEARCH", } + def _build_search_query(self, base_term: str, _label: str, question: str) -> str: + """Build a search query from the base term and context. + + Args: + base_term: The primary search term for this option. + _label: The branch label (unused, reserved for future use). + question: The original question. + + Returns: + A search query string. + """ + # Start with the base term + terms = [base_term] + + # Add a keyword from question (first 3 words, skip common words) + skip_words = {"should", "we", "the", "a", "an", "is", "are", "what", "how", "why"} + words = question.lower().split() + for word in words[:6]: + clean = word.strip("?.,!\"'") + if clean and clean not in skip_words and len(clean) > 2: + terms.append(clean) + break + + return " ".join(terms) + def _extract_excerpt(self, text: str, search_term: str) -> str: """Extract an excerpt containing the search term. @@ -291,6 +358,79 @@ def _extract_excerpt(self, text: str, search_term: str) -> str: return excerpt.strip() +class RefinerStub: + """Stub refiner agent that deterministically improves artifacts. + + Refines artifacts by: + 1. Marking weak claims as "refined" in the text + 2. Adding clarification notes for unsupported claims + 3. Producing a deterministically improved artifact + + This enables testing the refinement loop without LLM calls. + """ + + def execute(self, context: dict[str, Any]) -> dict[str, Any]: + """Refine an artifact based on validation feedback. + + Args: + context: Must contain: + - "node_id": Node ID for deterministic output + - "artifact": The current artifact dict + - "claims": List of claim dicts with "status", "claim_id", "text" + - "round": Current refinement round number + + Returns: + Dict with refined artifact and refinement notes. + """ + node_id = context.get("node_id", "") # noqa: F841 + artifact = context.get("artifact", {}) + claims = context.get("claims", []) + round_num = context.get("round", 1) + + # Count claim categories + weak_claims = [c for c in claims if c.get("status") == "weak"] + unsupported_claims = [c for c in claims if c.get("status") == "unsupported"] + + # Build refinement notes + refinement_notes: list[str] = [] + + # Deterministically "fix" weak claims + for claim in weak_claims[:2]: # Fix up to 2 weak claims per round + claim_id = claim.get("claim_id", "unknown") + refinement_notes.append(f"Strengthened weak claim {claim_id} with additional context") + + # Add clarification for unsupported claims + for claim in unsupported_claims[:1]: # Address up to 1 unsupported claim per round + claim_id = claim.get("claim_id", "unknown") + refinement_notes.append(f"Added clarification for unsupported claim {claim_id}") + + # Create refined artifact (shallow copy with updates) + refined_artifact = dict(artifact) + + # Mark as refined + refined_artifact["refinement_round"] = round_num + refined_artifact["refinement_notes"] = refinement_notes + + # Update summary to indicate refinement + original_summary = artifact.get("summary", "") + if original_summary: + refined_artifact["summary"] = f"{original_summary} [Refined in round {round_num}]" + + # Deterministically improve score (diminishing returns) + original_score = artifact.get("score", 0.5) + improvement = 0.05 / round_num # Smaller improvements each round + refined_artifact["score"] = min(1.0, original_score + improvement) + + return { + "artifact": refined_artifact, + "refinement_notes": refinement_notes, + "weak_addressed": len(weak_claims[:2]), + "unsupported_addressed": len(unsupported_claims[:1]), + "role": "refiner", + "step": "REFINE", + } + + class RedTeamStub: """Stub red team agent that raises objections deterministically. diff --git a/src/delibera/cli.py b/src/delibera/cli.py index cc3d3b7..b6bf070 100644 --- a/src/delibera/cli.py +++ b/src/delibera/cli.py @@ -6,11 +6,17 @@ import click +from delibera.__version__ import __version__ from delibera.engine.orchestrator import Engine from delibera.eval import EvalSuiteLoadError, load_eval_suite, run_eval_suite from delibera.gates import AutoApproveGateHandler, CLIGateHandler, GateAborted, GateHandler from delibera.gates.predicates import DEFAULT_TIE_THRESHOLD -from delibera.protocol import ProtocolLoadError, ProtocolSpec, load_protocol_from_yaml +from delibera.protocol import ( + ProtocolLoadError, + ProtocolSpec, + load_protocol_from_yaml, + warnings_for_protocol, +) from delibera.scoring import ScoreWeights from delibera.trace.reader import load_artifact from delibera.trace.replay import replay_from_directory, verify_replay @@ -51,11 +57,18 @@ def _parse_weights(weights_str: str) -> ScoreWeights: @click.group() +@click.version_option(version=__version__, prog_name="delibera") def main() -> None: """Delibera - An engine for decision-grade AI deliberation.""" pass +@main.command() +def version() -> None: + """Print the Delibera version.""" + click.echo(f"delibera {__version__}") + + @main.command() @click.option( "--question", @@ -92,6 +105,36 @@ def main() -> None: default=None, help="Path to YAML protocol file. If not provided, uses builtin default.", ) +@click.option( + "--use-llm-proposer", + is_flag=True, + default=False, + help="Use LLM-backed proposer instead of stub. Requires GEMINI_API_KEY.", +) +@click.option( + "--llm-provider", + type=click.Choice(["gemini"]), + default="gemini", + help="LLM provider to use. Default: gemini.", +) +@click.option( + "--llm-model", + type=str, + default=None, + help="LLM model name (e.g., 'gemini-1.5-flash'). If not set, uses provider default.", +) +@click.option( + "--llm-temperature", + type=float, + default=0.2, + help="LLM temperature (0.0-2.0). Default: 0.2.", +) +@click.option( + "--llm-max-output-tokens", + type=int, + default=800, + help="Maximum output tokens for LLM. Default: 800.", +) def run( question: str, gates: bool, @@ -99,6 +142,11 @@ def run( tie_threshold: float, weights: str | None, protocol: str | None, + use_llm_proposer: bool, + llm_provider: str, + llm_model: str | None, + llm_temperature: float, + llm_max_output_tokens: int, ) -> None: """Run a deliberation on the given question. @@ -110,6 +158,8 @@ def run( Use --tie-threshold to adjust when the tradeoff gate fires (lower = more sensitive). Use --weights to set scoring weights directly, which skips the tradeoff gate. Use --protocol to specify a YAML protocol file. + + To use LLM-backed proposer, set --use-llm-proposer and ensure GEMINI_API_KEY is set. """ # Determine gate handler gate_handler: GateHandler @@ -139,6 +189,10 @@ def run( try: protocol_spec = load_protocol_from_yaml(protocol_path) protocol_source = f"yaml:{protocol_path}" + # Show warnings for protocol configuration issues + warnings = warnings_for_protocol(protocol_spec) + for warning in warnings: + click.echo(f"Warning: {warning}", err=True) except ProtocolLoadError as e: click.echo(f"Error loading protocol: {e}", err=True) sys.exit(1) @@ -146,6 +200,21 @@ def run( click.echo(f"Error: {e}", err=True) sys.exit(1) + # Initialize LLM client if using LLM proposer + llm_client = None + if use_llm_proposer and llm_provider == "gemini": + from delibera.llm import GEMINI_API_KEY_ENV, GeminiClient, LLMAuthError + + try: + llm_client = GeminiClient( + model=llm_model or "gemini-1.5-flash", + ) + click.echo(f"LLM proposer enabled: {llm_provider} ({llm_model or 'default'})") + except LLMAuthError as e: + click.echo(f"Error: {e}", err=True) + click.echo(f"Set {GEMINI_API_KEY_ENV} environment variable.", err=True) + sys.exit(1) + engine = Engine( gate_handler=gate_handler, gates_enabled=gates_enabled, @@ -153,6 +222,11 @@ def run( initial_weights=initial_weights, protocol=protocol_spec, protocol_source=protocol_source, + llm_client=llm_client, + use_llm_proposer=use_llm_proposer, + llm_model=llm_model, + llm_temperature=llm_temperature, + llm_max_output_tokens=llm_max_output_tokens, ) try: @@ -164,6 +238,16 @@ def run( click.echo(f"Run aborted: {e.message}", err=True) click.echo(f"Gate: {e.gate_type.value}", err=True) sys.exit(1) + except KeyboardInterrupt: + click.echo("\nRun interrupted by user.", err=True) + sys.exit(130) + except ValueError as e: + click.echo(f"Error: {e}", err=True) + sys.exit(1) + except Exception as e: + click.echo(f"Error: Unexpected failure during run: {e}", err=True) + click.echo("Check that ANTHROPIC_API_KEY is set and valid.", err=True) + sys.exit(1) @main.command() @@ -346,5 +430,168 @@ def on_case_complete(result: "EvalResult") -> None: sys.exit(1) +@main.command() +@click.option( + "--run-id", + help="Run ID to inspect (looks in ./runs//).", +) +@click.option( + "--path", + type=click.Path(exists=True), + help="Path to run directory containing trace.jsonl.", +) +def inspect(run_id: str | None, path: str | None) -> None: + """Inspect a deliberation run and print a readable summary. + + This command loads the trace and artifact from a completed run + and displays a human-readable summary including: + + - Final recommendation + - Selected path through the deliberation tree + - Key claims with citations + - Pruning decisions + - Statistics + + This is read-only and does not create new runs or call agents. + + Use either --run-id or --path to specify the run to inspect. + """ + from delibera.inspect import build_run_summary, render_text + + # Determine run directory + if path: + run_dir = Path(path) + elif run_id: + run_dir = Path("runs") / run_id + else: + click.echo("Error: Must provide either --run-id or --path", err=True) + sys.exit(1) + + # Check directory exists + if not run_dir.exists(): + click.echo(f"Error: Run directory not found: {run_dir}", err=True) + sys.exit(1) + + # Check trace file exists + trace_path = run_dir / "trace.jsonl" + if not trace_path.exists(): + click.echo(f"Error: Trace file not found: {trace_path}", err=True) + sys.exit(1) + + # Build summary + try: + summary = build_run_summary(run_dir) + except Exception as e: + click.echo(f"Error: Failed to build summary: {e}", err=True) + sys.exit(1) + + # Render and print + output = render_text(summary) + click.echo(output) + + # Exit with error if there were errors in reconstruction + if summary.errors: + sys.exit(1) + + +@main.command() +@click.option( + "--run-id", + help="Run ID to generate report for (looks in ./runs//).", +) +@click.option( + "--path", + type=click.Path(exists=True), + help="Path to run directory containing trace.jsonl.", +) +@click.option( + "--format", + "output_format", + type=click.Choice(["md", "markdown"]), + default="md", + help="Output format. Default: md (Markdown).", +) +@click.option( + "--out", + required=True, + type=click.Path(), + help="Output file path for the report.", +) +def report( + run_id: str | None, + path: str | None, + output_format: str, + out: str, +) -> None: + """Generate a report from a deliberation run. + + This command loads the trace and artifact from a completed run + and generates a deterministic report file. + + The report includes: + - Run overview and metadata + - Final recommendation + - Decision explanation + - Selected path with node details + - Key claims with citations + - Pruning history + - Statistics + + This is read-only and does not create new runs or call agents. + + Use either --run-id or --path to specify the run. + """ + from delibera.inspect import build_run_summary, render_markdown + + # Determine run directory + if path: + run_dir = Path(path) + elif run_id: + run_dir = Path("runs") / run_id + else: + click.echo("Error: Must provide either --run-id or --path", err=True) + sys.exit(1) + + # Check directory exists + if not run_dir.exists(): + click.echo(f"Error: Run directory not found: {run_dir}", err=True) + sys.exit(1) + + # Check trace file exists + trace_path = run_dir / "trace.jsonl" + if not trace_path.exists(): + click.echo(f"Error: Trace file not found: {trace_path}", err=True) + sys.exit(1) + + # Build summary + try: + summary = build_run_summary(run_dir) + except Exception as e: + click.echo(f"Error: Failed to build summary: {e}", err=True) + sys.exit(1) + + # Render based on format + if output_format in ("md", "markdown"): + content = render_markdown(summary) + else: + # Fallback (shouldn't happen due to click.Choice) + content = render_markdown(summary) + + # Write output + out_path = Path(out) + try: + out_path.write_text(content, encoding="utf-8") + except Exception as e: + click.echo(f"Error: Failed to write report: {e}", err=True) + sys.exit(1) + + click.echo(f"Report written to: {out_path}") + + # Exit with error if there were errors in reconstruction + if summary.errors: + click.echo("Warning: Report generated with errors", err=True) + sys.exit(1) + + if __name__ == "__main__": main() diff --git a/src/delibera/engine/orchestrator.py b/src/delibera/engine/orchestrator.py index 6b0e133..cb1b34b 100644 --- a/src/delibera/engine/orchestrator.py +++ b/src/delibera/engine/orchestrator.py @@ -4,16 +4,33 @@ applies operators, enforces protocols, and determines convergence. """ +from __future__ import annotations + import uuid from datetime import UTC, datetime from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any + +from delibera.agents.stub import ( + PlannerStub, + ProposerStub, + RedTeamStub, + RefinerStub, + ResearcherStub, +) -from delibera.agents.stub import PlannerStub, ProposerStub, RedTeamStub, ResearcherStub +if TYPE_CHECKING: + from delibera.llm.base import LLMClient from delibera.engine import operators from delibera.engine.state import RunState from delibera.engine.tree import DeliberationTree -from delibera.epistemics.models import Evidence, Objection, ObjectionSeverity, ObjectionStatus +from delibera.epistemics.models import ( + ClaimStatus, + Evidence, + Objection, + ObjectionSeverity, + ObjectionStatus, +) from delibera.gates import ( GATE_ALLOWED_ACTIONS, AutoApproveGateHandler, @@ -75,6 +92,11 @@ def __init__( tie_threshold: float = DEFAULT_TIE_THRESHOLD, initial_weights: ScoreWeights | None = None, evidence_root: Path | None = None, + llm_client: LLMClient | None = None, + use_llm_proposer: bool = False, + llm_model: str | None = None, + llm_temperature: float = 0.2, + llm_max_output_tokens: int = 800, ) -> None: """Initialize the engine. @@ -89,6 +111,11 @@ def __init__( tie_threshold: Score difference below which tradeoff gate triggers. initial_weights: Initial scoring weights. If provided, tradeoff gate skipped. evidence_root: Root directory for evidence files. Defaults to ./evidence. + llm_client: Optional LLM client for LLM-backed agents. + use_llm_proposer: Whether to use LLM-backed proposer. Defaults to False. + llm_model: Model name for LLM (if different from client default). + llm_temperature: Temperature for LLM calls. Defaults to 0.2. + llm_max_output_tokens: Max output tokens for LLM. Defaults to 800. """ self.runs_dir = runs_dir or Path("runs") self._tool_registry = tool_registry or create_default_registry(evidence_root=evidence_root) @@ -100,6 +127,13 @@ def __init__( self._tie_threshold = tie_threshold self._initial_weights = initial_weights + # LLM configuration + self._llm_client = llm_client + self._use_llm_proposer = use_llm_proposer + self._llm_model = llm_model + self._llm_temperature = llm_temperature + self._llm_max_output_tokens = llm_max_output_tokens + # These are set during run execution self._current_run_id: str = "" self._current_writer: TraceWriter | None = None @@ -236,7 +270,19 @@ def run(self, question: str) -> Path: ) # PROPOSE: Generate proposals for each branch - proposer = ProposerStub() + proposer: ProposerStub | Any # Allow LLM proposer + if self._use_llm_proposer and self._llm_client is not None: + from delibera.agents.llm_proposer import ProposerLLM + + proposer = ProposerLLM( + llm_client=self._llm_client, + model=self._llm_model, + temperature=self._llm_temperature, + max_output_tokens=self._llm_max_output_tokens, + ) + else: + proposer = ProposerStub() + for child in children: # Create tool callback for this step tool_callback = self.make_tool_callback( @@ -244,10 +290,74 @@ def run(self, question: str) -> Path: role="proposer", step="PROPOSE", ) - propose_output = proposer.execute( - {"label": child.label, "question": question}, - tool=tool_callback, - ) + + # Emit LLM trace events if using LLM proposer + if self._use_llm_proposer and self._llm_client is not None: + # Build context with node_id for tracing + context = { + "label": child.label, + "question": question, + "node_id": child.node_id, + } + + # Emit llm_call_requested before the call + writer.emit( + TraceEvent( + event_type="llm_call_requested", + run_id=run_id, + payload={ + "node_id": child.node_id, + "role": "proposer", + "step": "PROPOSE", + "provider": "gemini", + "model": self._llm_model or "default", + }, + ) + ) + + try: + propose_output = proposer.execute(context, tool=tool_callback) + + # Emit llm_call_succeeded + writer.emit( + TraceEvent( + event_type="llm_call_succeeded", + run_id=run_id, + payload={ + "node_id": child.node_id, + "role": "proposer", + "step": "PROPOSE", + "output_length": len(str(propose_output)), + "llm_generated": True, + }, + ) + ) + except Exception as e: + # Emit llm_call_failed and fall back to stub + writer.emit( + TraceEvent( + event_type="llm_call_failed", + run_id=run_id, + payload={ + "node_id": child.node_id, + "role": "proposer", + "step": "PROPOSE", + "error_type": type(e).__name__, + "error_message": str(e)[:200], + }, + ) + ) + # Fall back to stub + propose_output = ProposerStub().execute( + {"label": child.label, "question": question}, + tool=tool_callback, + ) + else: + propose_output = proposer.execute( + {"label": child.label, "question": question}, + tool=tool_callback, + ) + tree.update_artifact(child.node_id, propose_output) writer.emit( @@ -452,9 +562,22 @@ def run(self, question: str) -> Path: ) ) + # REFINE LOOP: Execute bounded refinement if protocol defines it + refinement_metadata = self._execute_refine_loop( + merged_node=merged_node, + tree=tree, + run_id=run_id, + writer=writer, + current_weights=current_weights, + ) + # Build artifact for sign-off artifact = operators.finalize(state, merged_node) + # Add refinement metadata to artifact if refinement occurred + if refinement_metadata["total_rounds"] > 0: + artifact["refinement"] = refinement_metadata + # FINAL SIGN-OFF GATE: Get user approval for final output if needs_final_signoff_gate(artifact, self._gates_enabled): artifact = self._handle_final_signoff_gate( @@ -1007,3 +1130,268 @@ def _merge_objections_to_ledger( }, ) ) + + def _execute_refine_loop( + self, + merged_node: Any, + tree: DeliberationTree, + run_id: str, + writer: TraceWriter, + current_weights: ScoreWeights, + ) -> dict[str, Any]: + """Execute the bounded refinement loop on the merged node. + + Refinement continues until: + 1. max_rounds is reached + 2. All convergence predicates are satisfied: + - no_blocking_objections: No open blocking objections + - unsupported_claims == 0 + - weak_claims <= weak_threshold + - score_improvement < score_epsilon + + Args: + merged_node: The merged node to refine. + tree: The deliberation tree. + run_id: The current run ID. + writer: The trace writer. + current_weights: The current scoring weights. + + Returns: + Refinement metadata dict with total_rounds, converged, stop_reason, history. + """ + assert self._interpreter is not None # Set during run() + + # Check if refinement is enabled + if not self._interpreter.has_refine_loop(): + return { + "total_rounds": 0, + "converged": False, + "stop_reason": "refinement_disabled", + "history": [], + } + + convergence_spec = self._interpreter.get_convergence_spec() + max_rounds = convergence_spec.max_rounds + weak_threshold = convergence_spec.weak_threshold + score_epsilon = convergence_spec.score_epsilon + + history: list[dict[str, Any]] = [] + previous_score = merged_node.artifact.get("score", 0.0) + converged = False + stop_reason = "max_rounds_reached" + + refiner = RefinerStub() + + for round_num in range(1, max_rounds + 1): + # Emit round started event + writer.emit( + TraceEvent( + event_type="refinement_round_started", + run_id=run_id, + payload={ + "round": round_num, + "node_id": merged_node.node_id, + "max_rounds": max_rounds, + }, + ) + ) + + # Check convergence predicates before refining + converged, stop_reason = self._check_convergence( + merged_node=merged_node, + weak_threshold=weak_threshold, + score_epsilon=score_epsilon, + previous_score=previous_score, + current_score=merged_node.artifact.get("score", 0.0), + ) + + if converged: + # Record early convergence + history.append( + { + "round": round_num, + "action": "converged_early", + "reason": stop_reason, + } + ) + writer.emit( + TraceEvent( + event_type="refinement_round_completed", + run_id=run_id, + payload={ + "round": round_num, + "node_id": merged_node.node_id, + "converged": True, + "stop_reason": stop_reason, + }, + ) + ) + break + + # Build claims summary for refiner + claims_summary = [ + { + "claim_id": c.claim_id, + "claim_type": c.claim_type.value, + "status": c.status.value, + "text": c.text, + } + for c in merged_node.ledger.claims + ] + + # Execute refiner + refine_output = refiner.execute( + { + "node_id": merged_node.node_id, + "artifact": merged_node.artifact, + "claims": claims_summary, + "round": round_num, + } + ) + + # Update artifact with refined version + refined_artifact = refine_output.get("artifact", merged_node.artifact) + tree.update_artifact(merged_node.node_id, refined_artifact) + + # Emit work output for refine step + writer.emit( + TraceEvent( + event_type="work_output", + run_id=run_id, + payload={ + "step": "REFINE", + "node_id": merged_node.node_id, + "role": "refiner", + "round": round_num, + "output": refine_output, + }, + ) + ) + + # Re-validate claims after refinement + report = operators.validate(tree, merged_node.node_id, "refiner") + writer.emit( + TraceEvent( + event_type="claim_validation_report", + run_id=run_id, + payload={ + "node_id": merged_node.node_id, + "round": round_num, + "supported": report.supported, + "weak": report.weak, + "unsupported": report.unsupported, + "details": report.details, + "support_relations": report.support_relations, + }, + ) + ) + + # Rescore after refinement + score_result = score_node(merged_node, current_weights) + new_score = score_result.score + score_delta = new_score - previous_score + + # Record round history + round_record = { + "round": round_num, + "weak_addressed": refine_output.get("weak_addressed", 0), + "unsupported_addressed": refine_output.get("unsupported_addressed", 0), + "previous_score": previous_score, + "new_score": new_score, + "score_delta": score_delta, + "weak_claims": report.weak, + "unsupported_claims": report.unsupported, + } + history.append(round_record) + + # Emit round completed event + writer.emit( + TraceEvent( + event_type="refinement_round_completed", + run_id=run_id, + payload={ + "round": round_num, + "node_id": merged_node.node_id, + "converged": False, + "score_delta": score_delta, + "weak_claims": report.weak, + "unsupported_claims": report.unsupported, + }, + ) + ) + + previous_score = new_score + + # Final convergence check + if not converged: + converged, stop_reason = self._check_convergence( + merged_node=merged_node, + weak_threshold=weak_threshold, + score_epsilon=score_epsilon, + previous_score=previous_score, + current_score=merged_node.artifact.get("score", 0.0), + ) + if not converged: + stop_reason = "max_rounds_reached" + + return { + "total_rounds": len(history), + "converged": converged, + "stop_reason": stop_reason, + "history": history, + } + + def _check_convergence( + self, + merged_node: Any, + weak_threshold: int, + score_epsilon: float, + previous_score: float, + current_score: float, + ) -> tuple[bool, str]: + """Check if convergence predicates are satisfied. + + Predicates (all must be True): + 1. no_blocking_objections: No open blocking objections + 2. unsupported_claims == 0 + 3. weak_claims <= weak_threshold + 4. score_improvement < score_epsilon + + Args: + merged_node: The node to check. + weak_threshold: Maximum acceptable weak claims. + score_epsilon: Minimum score improvement to continue. + previous_score: Score before last refinement. + current_score: Score after last refinement. + + Returns: + Tuple of (converged, reason) where reason explains which predicate failed. + """ + # Check for open blocking objections + blocking_open = sum( + 1 + for obj in merged_node.ledger.objections + if obj.severity == ObjectionSeverity.BLOCKING and obj.status == ObjectionStatus.OPEN + ) + if blocking_open > 0: + return False, "blocking_objections_remain" + + # Count claim statuses + unsupported = sum( + 1 for c in merged_node.ledger.claims if c.status == ClaimStatus.UNSUPPORTED + ) + weak = sum(1 for c in merged_node.ledger.claims if c.status == ClaimStatus.WEAK) + + if unsupported > 0: + return False, "unsupported_claims_remain" + + if weak > weak_threshold: + return False, "weak_claims_exceed_threshold" + + # Check score improvement (if scores are close, we've converged) + score_delta = abs(current_score - previous_score) + if score_delta >= score_epsilon: + return False, "score_still_improving" + + # All predicates satisfied + return True, "all_predicates_satisfied" diff --git a/src/delibera/inspect/__init__.py b/src/delibera/inspect/__init__.py new file mode 100644 index 0000000..5ec0097 --- /dev/null +++ b/src/delibera/inspect/__init__.py @@ -0,0 +1,25 @@ +"""Run inspection and report generation for Delibera. + +This module provides tools for inspecting completed runs and generating +human-readable summaries and reports without re-running agents. +""" + +from delibera.inspect.render_md import render_markdown +from delibera.inspect.render_text import render_text +from delibera.inspect.summarize import ( + NodeSummary, + PruneEvent, + RunSummary, + build_run_summary, +) + +__all__ = [ + # Summary builder + "RunSummary", + "NodeSummary", + "PruneEvent", + "build_run_summary", + # Renderers + "render_text", + "render_markdown", +] diff --git a/src/delibera/inspect/render_md.py b/src/delibera/inspect/render_md.py new file mode 100644 index 0000000..b12b77f --- /dev/null +++ b/src/delibera/inspect/render_md.py @@ -0,0 +1,235 @@ +"""Render RunSummary to Markdown format. + +This renderer produces a deterministic Markdown document suitable +for documentation, reports, and version control. +""" + +from typing import Any + +from delibera.inspect.summarize import NodeSummary, RunSummary + + +def render_markdown(summary: RunSummary) -> str: + """Render a RunSummary to Markdown format. + + Args: + summary: The RunSummary to render. + + Returns: + Markdown document as a string. + """ + lines: list[str] = [] + + # Title + lines.append(f"# Deliberation Report: {summary.run_id}") + lines.append("") + + # Metadata table + lines.append("## Overview") + lines.append("") + lines.append("| Field | Value |") + lines.append("|-------|-------|") + lines.append(f"| Run ID | `{summary.run_id}` |") + lines.append(f"| Created | {summary.created_at} |") + lines.append(f"| Question | {_escape_md(summary.question)} |") + if summary.protocol.name: + lines.append(f"| Protocol | {summary.protocol.name} v{summary.protocol.version} |") + if summary.confidence > 0: + lines.append(f"| Confidence | {summary.confidence:.1%} |") + lines.append("") + + # Final Recommendation + lines.append("## Final Recommendation") + lines.append("") + if summary.recommendation: + lines.append(f"> {_escape_md(summary.recommendation)}") + else: + lines.append("*No recommendation available.*") + lines.append("") + + # Decision Explanation + if summary.decision_explanation: + lines.append("## Decision Explanation") + lines.append("") + _render_decision_explanation_md(lines, summary.decision_explanation) + lines.append("") + + # Selected Path + lines.append("## Selected Path") + lines.append("") + if summary.selected_path: + lines.append("The following nodes were selected in the final decision:") + lines.append("") + for node in summary.selected_path: + _render_node_summary_md(lines, node) + else: + lines.append("*No path information available.*") + lines.append("") + + # Key Claims + if summary.key_claims: + lines.append("## Key Claims") + lines.append("") + for claim in summary.key_claims: + status_badge = _status_badge(claim.status) + lines.append(f"### {claim.claim_type.capitalize()}: {_escape_md(claim.text)}") + lines.append("") + lines.append(f"**Status:** {status_badge}") + lines.append("") + if claim.citations: + lines.append("**Citations:**") + lines.append("") + for cit in claim.citations: + lines.append(f"- **{cit.source}**") + if cit.excerpt: + if len(cit.excerpt) > 150: + excerpt = cit.excerpt[:150] + "..." + else: + excerpt = cit.excerpt + lines.append(f" > {_escape_md(excerpt)}") + lines.append("") + + # Pruning History + if summary.prune_events: + lines.append("## Pruning History") + lines.append("") + for i, prune in enumerate(summary.prune_events, 1): + lines.append(f"### Prune #{i}") + lines.append("") + lines.append(f"- **Kept:** {', '.join(prune.kept) or '(none)'}") + lines.append(f"- **Pruned:** {', '.join(prune.pruned) or '(none)'}") + if prune.reason: + lines.append(f"- **Reason:** {prune.reason}") + lines.append("") + + # Convergence + if summary.refinement.stop_reason: + lines.append("## Convergence") + lines.append("") + lines.append(f"- **Stop reason:** {summary.refinement.stop_reason}") + if summary.refinement.rounds_run > 0: + lines.append( + f"- **Rounds:** {summary.refinement.rounds_run} / {summary.refinement.max_rounds}" + ) + lines.append("") + + # Statistics + lines.append("## Statistics") + lines.append("") + lines.append("| Metric | Value |") + lines.append("|--------|-------|") + lines.append(f"| Total nodes | {summary.total_nodes} |") + lines.append(f"| Tool calls | {summary.total_tool_calls} |") + lines.append(f"| Evidence items | {summary.total_evidence} |") + lines.append("") + + # Errors and Warnings + if summary.errors or summary.warnings: + lines.append("## Issues") + lines.append("") + if summary.errors: + lines.append("### Errors") + lines.append("") + for error in summary.errors: + lines.append(f"- {_escape_md(error)}") + lines.append("") + if summary.warnings: + lines.append("### Warnings") + lines.append("") + for warning in summary.warnings: + lines.append(f"- {_escape_md(warning)}") + lines.append("") + + # Footer + lines.append("---") + lines.append("") + lines.append("*Generated by Delibera*") + lines.append("") + + return "\n".join(lines) + + +def _escape_md(text: str) -> str: + """Escape special Markdown characters in text.""" + # Escape pipe characters for tables + text = text.replace("|", "\\|") + # Escape newlines + text = text.replace("\n", " ") + return text + + +def _status_badge(status: str) -> str: + """Return a badge-like indicator for claim status.""" + badges = { + "supported": "Supported", + "weak": "Weak", + "unsupported": "Unsupported", + } + return badges.get(status.lower(), status) + + +def _render_node_summary_md(lines: list[str], node: NodeSummary) -> None: + """Render a single node summary in Markdown.""" + # Node header + label = node.label or f"({node.kind})" + + lines.append(f"### {node.kind.capitalize()}: {label}") + lines.append("") + + # Details list + if node.score is not None: + lines.append(f"- **Score:** {node.score:.2f}") + + if node.claim_counts: + supported = node.claim_counts.get("supported", 0) + weak = node.claim_counts.get("weak", 0) + unsupported = node.claim_counts.get("unsupported", 0) + lines.append(f"- **Claims:** {supported} supported, {weak} weak, {unsupported} unsupported") + + if node.evidence_count > 0: + lines.append(f"- **Evidence:** {node.evidence_count} items") + if node.evidence_sources: + sources_str = ", ".join(f"`{s}`" for s in node.evidence_sources[:3]) + if len(node.evidence_sources) > 3: + sources_str += f" (+{len(node.evidence_sources) - 3} more)" + lines.append(f" - Sources: {sources_str}") + + obj = node.objections + total_blocking = obj.blocking_open + obj.blocking_accepted + total_nonblocking = obj.nonblocking_open + obj.nonblocking_accepted + if total_blocking > 0 or total_nonblocking > 0: + lines.append( + f"- **Objections:** " + f"{obj.blocking_accepted}/{total_blocking} blocking, " + f"{obj.nonblocking_accepted}/{total_nonblocking} nonblocking" + ) + + lines.append("") + + +def _render_decision_explanation_md(lines: list[str], explanation: dict[str, Any]) -> None: + """Render decision explanation in Markdown.""" + # Ranking table + ranking = explanation.get("ranking", []) + if ranking: + lines.append("### Ranking") + lines.append("") + lines.append("| Score | Option | Status |") + lines.append("|-------|--------|--------|") + for entry in ranking: + label = entry.get("label", "Unknown") + score = entry.get("score", 0.0) + status = entry.get("status", "") + lines.append(f"| {score:.2f} | {_escape_md(label)} | {status} |") + lines.append("") + + # Weights table + weights = explanation.get("weights_used", {}) + if weights: + lines.append("### Scoring Weights") + lines.append("") + lines.append("| Weight | Value |") + lines.append("|--------|-------|") + for key, value in sorted(weights.items()): + lines.append(f"| {key} | {value:.2f} |") + lines.append("") diff --git a/src/delibera/inspect/render_text.py b/src/delibera/inspect/render_text.py new file mode 100644 index 0000000..c1c4f67 --- /dev/null +++ b/src/delibera/inspect/render_text.py @@ -0,0 +1,201 @@ +"""Render RunSummary to human-readable terminal text. + +This renderer produces plain text output suitable for terminal display. +No external dependencies are required. +""" + +from typing import Any + +from delibera.inspect.summarize import NodeSummary, RunSummary + + +def render_text(summary: RunSummary) -> str: + """Render a RunSummary to plain text. + + Args: + summary: The RunSummary to render. + + Returns: + Human-readable text representation. + """ + lines: list[str] = [] + + # Header + lines.append("=" * 60) + lines.append("DELIBERATION RUN SUMMARY") + lines.append("=" * 60) + lines.append("") + + # Run identification + lines.append(f"Run ID: {summary.run_id}") + lines.append(f"Created: {summary.created_at}") + lines.append(f"Question: {summary.question}") + lines.append("") + + # Protocol info + if summary.protocol.name: + lines.append("--- Protocol ---") + lines.append(f"Name: {summary.protocol.name}") + if summary.protocol.version: + lines.append(f"Version: {summary.protocol.version}") + if summary.protocol.source: + lines.append(f"Source: {summary.protocol.source}") + lines.append("") + + # Final Recommendation + lines.append("--- Final Recommendation ---") + if summary.recommendation: + lines.append(summary.recommendation) + else: + lines.append("(No recommendation available)") + lines.append("") + + if summary.confidence > 0: + lines.append(f"Confidence: {summary.confidence:.1%}") + lines.append("") + + # Decision Explanation + if summary.decision_explanation: + lines.append("--- Decision Explanation ---") + _render_decision_explanation(lines, summary.decision_explanation) + lines.append("") + + # Refinement/Convergence + if summary.refinement.stop_reason: + lines.append("--- Convergence ---") + lines.append(f"Stop reason: {summary.refinement.stop_reason}") + if summary.refinement.rounds_run > 0: + lines.append( + f"Rounds: {summary.refinement.rounds_run} / {summary.refinement.max_rounds}" + ) + lines.append("") + + # Selected Path + lines.append("--- Selected Path ---") + if summary.selected_path: + for node in summary.selected_path: + _render_node_summary(lines, node) + else: + lines.append("(No path information available)") + lines.append("") + + # Key Claims with Citations + if summary.key_claims: + lines.append("--- Key Claims ---") + for i, claim in enumerate(summary.key_claims, 1): + lines.append(f"{i}. [{claim.claim_type.upper()}] {claim.text}") + lines.append(f" Status: {claim.status}") + if claim.citations: + lines.append(" Citations:") + for cit in claim.citations: + lines.append(f" - {cit.source}") + if cit.excerpt: + # Truncate excerpt for display + if len(cit.excerpt) > 100: + excerpt = cit.excerpt[:100] + "..." + else: + excerpt = cit.excerpt + lines.append(f' "{excerpt}"') + lines.append("") + + # Pruning History + if summary.prune_events: + lines.append("--- Pruning History ---") + for i, prune in enumerate(summary.prune_events, 1): + lines.append(f"Prune #{i}:") + lines.append(f" Kept: {', '.join(prune.kept) or '(none)'}") + lines.append(f" Pruned: {', '.join(prune.pruned) or '(none)'}") + if prune.reason: + lines.append(f" Reason: {prune.reason}") + lines.append("") + + # Statistics + lines.append("--- Statistics ---") + lines.append(f"Total nodes: {summary.total_nodes}") + lines.append(f"Tool calls: {summary.total_tool_calls}") + lines.append(f"Evidence items: {summary.total_evidence}") + lines.append("") + + # Errors and Warnings + if summary.errors: + lines.append("--- Errors ---") + for error in summary.errors: + lines.append(f" ERROR: {error}") + lines.append("") + + if summary.warnings: + lines.append("--- Warnings ---") + for warning in summary.warnings: + lines.append(f" WARNING: {warning}") + lines.append("") + + lines.append("=" * 60) + + return "\n".join(lines) + + +def _render_node_summary(lines: list[str], node: NodeSummary) -> None: + """Render a single node summary.""" + indent = " " * node.depth + + # Node header + label = node.label or f"({node.kind})" + lines.append(f"{indent}[{node.kind.upper()}] {label}") + + # Score + if node.score is not None: + lines.append(f"{indent} Score: {node.score:.2f}") + if node.score_breakdown: + breakdown_parts = [f"{k}={v:.2f}" for k, v in sorted(node.score_breakdown.items())] + lines.append(f"{indent} Breakdown: {', '.join(breakdown_parts)}") + + # Claims + if node.claim_counts: + supported = node.claim_counts.get("supported", 0) + weak = node.claim_counts.get("weak", 0) + unsupported = node.claim_counts.get("unsupported", 0) + lines.append( + f"{indent} Claims: {supported} supported, {weak} weak, {unsupported} unsupported" + ) + + # Evidence + if node.evidence_count > 0: + lines.append(f"{indent} Evidence: {node.evidence_count} items") + if node.evidence_sources: + for source in node.evidence_sources[:3]: # Show top 3 + lines.append(f"{indent} - {source}") + if len(node.evidence_sources) > 3: + lines.append(f"{indent} ... and {len(node.evidence_sources) - 3} more") + + # Objections + obj = node.objections + total_blocking = obj.blocking_open + obj.blocking_accepted + total_nonblocking = obj.nonblocking_open + obj.nonblocking_accepted + if total_blocking > 0 or total_nonblocking > 0: + lines.append( + f"{indent} Objections: " + f"{obj.blocking_accepted}/{total_blocking} blocking accepted, " + f"{obj.nonblocking_accepted}/{total_nonblocking} nonblocking accepted" + ) + + lines.append("") + + +def _render_decision_explanation(lines: list[str], explanation: dict[str, Any]) -> None: + """Render decision explanation section.""" + # Ranking + ranking = explanation.get("ranking", []) + if ranking: + lines.append("Ranking:") + for entry in ranking: + label = entry.get("label", "Unknown") + score = entry.get("score", 0.0) + status = entry.get("status", "") + lines.append(f" {score:.2f} - {label} ({status})") + + # Weights used + weights = explanation.get("weights_used", {}) + if weights: + lines.append("Weights:") + for key, value in sorted(weights.items()): + lines.append(f" {key}: {value:.2f}") diff --git a/src/delibera/inspect/summarize.py b/src/delibera/inspect/summarize.py new file mode 100644 index 0000000..5e9f4cd --- /dev/null +++ b/src/delibera/inspect/summarize.py @@ -0,0 +1,418 @@ +"""Build structured RunSummary from replay and trace data. + +This module constructs a comprehensive summary of a deliberation run +using the replayed tree state, trace events, and final artifact. +""" + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from delibera.trace.reader import load_artifact, load_trace +from delibera.trace.replay import ReplayedRun, replay_run + + +@dataclass +class Citation: + """A citation linking a claim to evidence.""" + + evidence_id: str + source: str + excerpt: str # Truncated to max 200 chars + + +@dataclass +class KeyClaim: + """A key claim from the final artifact with citations.""" + + claim_id: str + claim_type: str # fact, inference, plan + text: str + status: str # supported, weak, unsupported + citations: list[Citation] = field(default_factory=list) + + +@dataclass +class ObjectionSummary: + """Summary of objections for a node.""" + + blocking_open: int = 0 + blocking_accepted: int = 0 + nonblocking_open: int = 0 + nonblocking_accepted: int = 0 + + +@dataclass +class NodeSummary: + """Summary of a single node on the selected path.""" + + node_id: str + label: str + kind: str # root, option, merged + depth: int + score: float | None = None + score_breakdown: dict[str, float] = field(default_factory=dict) + claim_counts: dict[str, int] = field(default_factory=dict) # supported/weak/unsupported + evidence_sources: list[str] = field(default_factory=list) # Top N sources + evidence_count: int = 0 + objections: ObjectionSummary = field(default_factory=ObjectionSummary) + + +@dataclass +class PruneEvent: + """Summary of a single prune decision.""" + + candidates: list[str] # node_ids considered + kept: list[str] # node_ids kept (survivors) + pruned: list[str] # node_ids pruned + reason: str = "" # Summary reason for pruning + + +@dataclass +class RefinementInfo: + """Information about refinement rounds.""" + + rounds_run: int = 0 + max_rounds: int = 0 + stop_reason: str = "" # converged, max_rounds, etc. + + +@dataclass +class ProtocolInfo: + """Information about the protocol used.""" + + name: str = "" + version: str = "" + source: str = "" # yaml:path, builtin, etc. + + +@dataclass +class RunSummary: + """Complete summary of a deliberation run. + + This is the main data structure used by renderers to produce + human-readable output. + """ + + # Run identification + run_id: str + question: str + created_at: str + + # Protocol info + protocol: ProtocolInfo = field(default_factory=ProtocolInfo) + + # Final outcome + recommendation: str = "" + confidence: float = 0.0 + decision_explanation: dict[str, Any] = field(default_factory=dict) + + # Convergence info + refinement: RefinementInfo = field(default_factory=RefinementInfo) + + # Selected path (root -> leaf in order) + selected_path: list[NodeSummary] = field(default_factory=list) + + # All key claims with citations (from artifact) + key_claims: list[KeyClaim] = field(default_factory=list) + + # Pruning history + prune_events: list[PruneEvent] = field(default_factory=list) + + # Aggregate stats + total_nodes: int = 0 + total_tool_calls: int = 0 + total_evidence: int = 0 + + # Errors/warnings from reconstruction + errors: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + + +# Limits for deterministic output +MAX_EVIDENCE_SOURCES = 5 +MAX_KEY_CLAIMS = 5 +MAX_EXCERPT_LENGTH = 200 + + +def build_run_summary(run_dir: Path) -> RunSummary: + """Build a RunSummary from a run directory. + + Args: + run_dir: Path to run directory containing trace.jsonl and artifact.json. + + Returns: + RunSummary with all available information. + + Raises: + FileNotFoundError: If trace.jsonl does not exist. + """ + trace_path = run_dir / "trace.jsonl" + artifact_path = run_dir / "artifact.json" + + # Load trace events + events = load_trace(trace_path) + + # Replay to get tree structure + replayed = replay_run( + events, + artifact_path=artifact_path if artifact_path.exists() else None, + ) + + # Load artifact for final truth + artifact: dict[str, Any] = {} + if artifact_path.exists(): + artifact = load_artifact(artifact_path) + + # Build summary + summary = RunSummary( + run_id=replayed.run_id, + question=replayed.question, + created_at=replayed.created_at, + ) + + # Copy errors/warnings from replay + summary.errors.extend(replayed.errors) + summary.warnings.extend(replayed.warnings) + + # Extract info from events + _extract_protocol_info(summary, events) + _extract_refinement_info(summary, events) + _extract_prune_events(summary, events, replayed) + _extract_tool_stats(summary, events) + _extract_evidence_stats(summary, events) + + # Build selected path + _build_selected_path(summary, replayed, events) + + # Extract from artifact + _extract_from_artifact(summary, artifact) + + # Set total nodes + summary.total_nodes = len(replayed.nodes) + + return summary + + +def _extract_protocol_info(summary: RunSummary, events: list[dict[str, Any]]) -> None: + """Extract protocol information from run_start event.""" + for event in events: + if event.get("event_type") == "run_start": + payload = event.get("payload", {}) + summary.protocol.name = payload.get("protocol", "") + summary.protocol.version = payload.get("protocol_version", "") + summary.protocol.source = payload.get("protocol_source", "") + break + + +def _extract_refinement_info(summary: RunSummary, events: list[dict[str, Any]]) -> None: + """Extract refinement/convergence information from events.""" + # Look for convergence_checked or run_end events + for event in events: + event_type = event.get("event_type") + payload = event.get("payload", {}) + + if event_type == "convergence_checked": + summary.refinement.rounds_run = payload.get("rounds_run", 0) + summary.refinement.max_rounds = payload.get("max_rounds", 0) + if payload.get("converged"): + summary.refinement.stop_reason = "converged" + + elif event_type == "run_end": + status = payload.get("status", "") + if status and not summary.refinement.stop_reason: + summary.refinement.stop_reason = status + + +def _extract_prune_events( + summary: RunSummary, + events: list[dict[str, Any]], + replayed: ReplayedRun, +) -> None: + """Extract pruning history from prune events.""" + for event in events: + if event.get("event_type") == "prune": + payload = event.get("payload", {}) + + survivor_ids = payload.get("survivor_ids", []) + pruned_ids = payload.get("pruned_ids", []) + + # Get labels for readability + def get_label(node_id: str) -> str: + if node_id in replayed.nodes: + return replayed.nodes[node_id].label or node_id + return node_id + + prune_event = PruneEvent( + candidates=sorted(survivor_ids + pruned_ids), # All candidates + kept=[get_label(nid) for nid in sorted(survivor_ids)], + pruned=[get_label(nid) for nid in sorted(pruned_ids)], + reason=payload.get("reason", "epistemic_score"), + ) + summary.prune_events.append(prune_event) + + +def _extract_tool_stats(summary: RunSummary, events: list[dict[str, Any]]) -> None: + """Count tool calls from trace.""" + count = 0 + for event in events: + if event.get("event_type") == "tool_call_executed": + count += 1 + summary.total_tool_calls = count + + +def _extract_evidence_stats(summary: RunSummary, events: list[dict[str, Any]]) -> None: + """Count evidence from trace.""" + count = 0 + for event in events: + if event.get("event_type") == "evidence_added": + count += 1 + summary.total_evidence = count + + +def _build_selected_path( + summary: RunSummary, + replayed: ReplayedRun, + events: list[dict[str, Any]], +) -> None: + """Build the selected path from root to merged/final node.""" + # Build score lookup from score_computed events + node_scores: dict[str, dict[str, Any]] = {} + for event in events: + if event.get("event_type") == "score_computed": + payload = event.get("payload", {}) + node_id = payload.get("node_id", "") + if node_id: + node_scores[node_id] = { + "score": payload.get("score", 0.0), + "breakdown": payload.get("breakdown", {}), + } + + # Build evidence sources lookup per node + node_evidence: dict[str, list[str]] = {} + for event in events: + if event.get("event_type") == "evidence_added": + payload = event.get("payload", {}) + node_id = payload.get("node_id", "") + evidence = payload.get("evidence", {}) + source = evidence.get("source", "") + if node_id and source: + if node_id not in node_evidence: + node_evidence[node_id] = [] + if source not in node_evidence[node_id]: + node_evidence[node_id].append(source) + + # Build objections lookup per node + node_objections: dict[str, ObjectionSummary] = {} + for event in events: + if event.get("event_type") == "objection_added": + payload = event.get("payload", {}) + node_id = payload.get("node_id", "") + blocking = payload.get("blocking", False) + if node_id: + if node_id not in node_objections: + node_objections[node_id] = ObjectionSummary() + if blocking: + node_objections[node_id].blocking_open += 1 + else: + node_objections[node_id].nonblocking_open += 1 + + elif event.get("event_type") == "objection_resolved": + payload = event.get("payload", {}) + node_id = payload.get("node_id", "") + blocking = payload.get("blocking", False) + accepted = payload.get("accepted", False) + if node_id and node_id in node_objections: + if blocking: + node_objections[node_id].blocking_open -= 1 + if accepted: + node_objections[node_id].blocking_accepted += 1 + else: + node_objections[node_id].nonblocking_open -= 1 + if accepted: + node_objections[node_id].nonblocking_accepted += 1 + + # Find path from root to merged (or last active option) + path_node_ids: list[str] = [] + + # Start with root + if replayed.root_id: + path_node_ids.append(replayed.root_id) + + # Add survivor options that were merged + survivor_ids = [ + nid + for nid, node in replayed.nodes.items() + if node.kind == "option" and node.status == "merged" + ] + # Sort by depth, then by node_id for determinism + survivor_ids.sort(key=lambda nid: (replayed.nodes[nid].depth, nid)) + path_node_ids.extend(survivor_ids) + + # Add merged node if exists + if replayed.merged_id and replayed.merged_id not in path_node_ids: + path_node_ids.append(replayed.merged_id) + + # Build NodeSummary for each node on path + for node_id in path_node_ids: + if node_id not in replayed.nodes: + continue + + node = replayed.nodes[node_id] + + # Get score info + score_info = node_scores.get(node_id, {}) + + # Get evidence sources (limited) + sources = node_evidence.get(node_id, [])[:MAX_EVIDENCE_SOURCES] + + # Get claim counts + claim_counts: dict[str, int] = {} + if node.claim_summary: + claim_counts = dict(node.claim_summary) + + node_summary = NodeSummary( + node_id=node_id, + label=node.label, + kind=node.kind, + depth=node.depth, + score=score_info.get("score"), + score_breakdown=score_info.get("breakdown", {}), + claim_counts=claim_counts, + evidence_sources=sources, + evidence_count=len(node_evidence.get(node_id, [])), + objections=node_objections.get(node_id, ObjectionSummary()), + ) + summary.selected_path.append(node_summary) + + +def _extract_from_artifact(summary: RunSummary, artifact: dict[str, Any]) -> None: + """Extract final outcome information from artifact.json.""" + summary.recommendation = artifact.get("recommendation", "") + summary.confidence = artifact.get("confidence", 0.0) + summary.decision_explanation = artifact.get("decision_explanation", {}) + + # Extract key_claims with citations + raw_claims = artifact.get("key_claims", []) + for raw in raw_claims[:MAX_KEY_CLAIMS]: + citations = [] + for cit in raw.get("citations", []): + excerpt = cit.get("excerpt", "") + if len(excerpt) > MAX_EXCERPT_LENGTH: + excerpt = excerpt[:MAX_EXCERPT_LENGTH] + "..." + citations.append( + Citation( + evidence_id=cit.get("evidence_id", ""), + source=cit.get("source", ""), + excerpt=excerpt, + ) + ) + + key_claim = KeyClaim( + claim_id=raw.get("claim_id", ""), + claim_type=raw.get("type", ""), + text=raw.get("text", ""), + status=raw.get("status", ""), + citations=citations, + ) + summary.key_claims.append(key_claim) diff --git a/src/delibera/llm/__init__.py b/src/delibera/llm/__init__.py new file mode 100644 index 0000000..c223400 --- /dev/null +++ b/src/delibera/llm/__init__.py @@ -0,0 +1,60 @@ +"""LLM subsystem for Delibera. + +Provides abstraction for LLM providers with Gemini as the first implementation. +LLMs are used only in WORK steps and never during validation. +""" + +from delibera.llm.base import ( + LLMAuthError, + LLMClient, + LLMConnectionError, + LLMError, + LLMInvalidResponseError, + LLMMessage, + LLMRateLimitError, + LLMRequest, + LLMResponse, + LLMUsage, +) +from delibera.llm.gemini import DEFAULT_GEMINI_MODEL, GEMINI_API_KEY_ENV, GeminiClient +from delibera.llm.prompts import ( + PROPOSER_JSON_SCHEMA, + build_proposer_system_prompt, + build_proposer_user_prompt, +) +from delibera.llm.redaction import ( + create_llm_error_trace, + create_llm_request_trace, + create_llm_response_trace, + redact_text, + summarize_messages, +) + +__all__ = [ + # Base types + "LLMMessage", + "LLMRequest", + "LLMResponse", + "LLMUsage", + "LLMClient", + # Errors + "LLMError", + "LLMRateLimitError", + "LLMAuthError", + "LLMInvalidResponseError", + "LLMConnectionError", + # Gemini + "GeminiClient", + "DEFAULT_GEMINI_MODEL", + "GEMINI_API_KEY_ENV", + # Prompts + "PROPOSER_JSON_SCHEMA", + "build_proposer_system_prompt", + "build_proposer_user_prompt", + # Redaction + "redact_text", + "summarize_messages", + "create_llm_request_trace", + "create_llm_response_trace", + "create_llm_error_trace", +] diff --git a/src/delibera/llm/base.py b/src/delibera/llm/base.py new file mode 100644 index 0000000..248acdf --- /dev/null +++ b/src/delibera/llm/base.py @@ -0,0 +1,175 @@ +"""LLM abstraction layer for Delibera. + +Provides a stable interface for LLM providers with structured request/response types. +LLMs are used only in WORK steps and never during validation. +""" + +from dataclasses import dataclass, field +from typing import Any, Literal, Protocol + + +@dataclass +class LLMMessage: + """A single message in an LLM conversation. + + Attributes: + role: The role of the message sender. + content: The message content. + """ + + role: Literal["system", "user", "assistant"] + content: str + + +@dataclass +class LLMRequest: + """A request to an LLM provider. + + Attributes: + model: The model identifier (e.g., "gemini-1.5-pro"). + messages: The conversation messages. + temperature: Sampling temperature (0.0-2.0). None uses provider default. + max_output_tokens: Maximum tokens in response. None uses provider default. + response_format: Expected response format ("json" or "text"). + json_schema: Optional JSON schema for structured output. + metadata: Trace tags for logging (run_id, node_id, step_id, role). + """ + + model: str + messages: list[LLMMessage] + temperature: float | None = None + max_output_tokens: int | None = None + response_format: Literal["json", "text"] = "text" + json_schema: dict[str, Any] | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary for serialization.""" + return { + "model": self.model, + "messages": [{"role": m.role, "content": m.content} for m in self.messages], + "temperature": self.temperature, + "max_output_tokens": self.max_output_tokens, + "response_format": self.response_format, + "json_schema": self.json_schema, + "metadata": self.metadata, + } + + +@dataclass +class LLMUsage: + """Token usage information from an LLM response. + + Attributes: + input_tokens: Number of input/prompt tokens. + output_tokens: Number of output/completion tokens. + total_tokens: Total tokens (input + output). + """ + + input_tokens: int | None = None + output_tokens: int | None = None + total_tokens: int | None = None + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary for serialization.""" + result: dict[str, Any] = {} + if self.input_tokens is not None: + result["input_tokens"] = self.input_tokens + if self.output_tokens is not None: + result["output_tokens"] = self.output_tokens + if self.total_tokens is not None: + result["total_tokens"] = self.total_tokens + return result + + +@dataclass +class LLMResponse: + """A response from an LLM provider. + + Attributes: + text: The raw text response. + parsed_json: Parsed JSON if response_format was "json" and parsing succeeded. + usage: Token usage information if available. + provider: The provider name (e.g., "gemini"). + model: The actual model used. + latency_ms: Response latency in milliseconds. + """ + + text: str + parsed_json: dict[str, Any] | None = None + usage: LLMUsage | None = None + provider: str = "" + model: str = "" + latency_ms: int | None = None + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary for serialization.""" + return { + "text": self.text, + "parsed_json": self.parsed_json, + "usage": self.usage.to_dict() if self.usage else None, + "provider": self.provider, + "model": self.model, + "latency_ms": self.latency_ms, + } + + +class LLMClient(Protocol): + """Protocol defining the LLM client interface. + + Implementations must provide a synchronous generate method. + """ + + def generate(self, request: LLMRequest) -> LLMResponse: + """Generate a response from the LLM. + + Args: + request: The LLM request with messages and parameters. + + Returns: + The LLM response with text and optional parsed JSON. + + Raises: + LLMError: If the request fails. + """ + ... + + +class LLMError(Exception): + """Base exception for LLM errors.""" + + def __init__(self, message: str, provider: str = "", model: str = "") -> None: + """Initialize LLM error. + + Args: + message: Error message. + provider: The provider that raised the error. + model: The model that was being used. + """ + self.provider = provider + self.model = model + super().__init__(message) + + +class LLMRateLimitError(LLMError): + """Raised when rate limits are exceeded.""" + + pass + + +class LLMAuthError(LLMError): + """Raised when authentication fails.""" + + pass + + +class LLMInvalidResponseError(LLMError): + """Raised when the LLM response is invalid or unparseable.""" + + pass + + +class LLMConnectionError(LLMError): + """Raised when connection to the LLM provider fails.""" + + pass diff --git a/src/delibera/llm/gemini.py b/src/delibera/llm/gemini.py new file mode 100644 index 0000000..bea538d --- /dev/null +++ b/src/delibera/llm/gemini.py @@ -0,0 +1,416 @@ +"""Gemini LLM client implementation. + +Provides a GeminiClient that implements the LLMClient interface using +the Google Gemini API via the official SDK. +""" + +import json +import os +import time +from typing import Any + +from delibera.llm.base import ( + LLMAuthError, + LLMConnectionError, + LLMError, + LLMInvalidResponseError, + LLMRateLimitError, + LLMRequest, + LLMResponse, + LLMUsage, +) + +# Default model for Gemini +DEFAULT_GEMINI_MODEL = "gemini-1.5-flash" + +# Environment variable for API key +GEMINI_API_KEY_ENV = "GEMINI_API_KEY" + + +def _get_api_key() -> str: + """Get the Gemini API key from environment. + + Returns: + The API key. + + Raises: + LLMAuthError: If the API key is not set. + """ + api_key = os.environ.get(GEMINI_API_KEY_ENV) + if not api_key: + raise LLMAuthError( + f"{GEMINI_API_KEY_ENV} environment variable not set", + provider="gemini", + ) + return api_key + + +class GeminiClient: + """LLM client for Google Gemini API. + + Uses the official google-generativeai SDK when available. + Falls back to HTTP requests if SDK is not installed. + + Attributes: + model: The default model to use. + """ + + def __init__( + self, + model: str = DEFAULT_GEMINI_MODEL, + api_key: str | None = None, + ) -> None: + """Initialize the Gemini client. + + Args: + model: Default model name. Defaults to gemini-1.5-flash. + api_key: API key. If None, reads from GEMINI_API_KEY env var. + + Raises: + LLMAuthError: If API key is not available. + """ + self.model = model + self._api_key = api_key or _get_api_key() + self._sdk_available = False + self._genai: Any = None + + # Try to import the SDK + try: + import google.generativeai as genai + + genai.configure(api_key=self._api_key) + self._genai = genai + self._sdk_available = True + except ImportError: + # SDK not available, will use HTTP fallback + pass + + def generate(self, request: LLMRequest) -> LLMResponse: + """Generate a response from Gemini. + + Args: + request: The LLM request. + + Returns: + The LLM response. + + Raises: + LLMError: If the request fails. + """ + model = request.model or self.model + start_time = time.monotonic() + + try: + if self._sdk_available: + response = self._generate_with_sdk(request, model) + else: + response = self._generate_with_http(request, model) + + latency_ms = int((time.monotonic() - start_time) * 1000) + response.latency_ms = latency_ms + + # Parse JSON if requested + if request.response_format == "json" and response.parsed_json is None: + response.parsed_json = self._parse_json_response(response.text) + + return response + + except LLMError: + raise + except Exception as e: + raise LLMConnectionError( + f"Failed to connect to Gemini: {e}", + provider="gemini", + model=model, + ) from e + + def _generate_with_sdk(self, request: LLMRequest, model: str) -> LLMResponse: + """Generate using the official SDK. + + Args: + request: The LLM request. + model: The model to use. + + Returns: + The LLM response. + """ + genai = self._genai + + # Build generation config + generation_config: dict[str, Any] = {} + if request.temperature is not None: + generation_config["temperature"] = request.temperature + if request.max_output_tokens is not None: + generation_config["max_output_tokens"] = request.max_output_tokens + + # Set response MIME type for JSON + if request.response_format == "json": + generation_config["response_mime_type"] = "application/json" + + # Create the model + try: + genai_model = genai.GenerativeModel( + model_name=model, + generation_config=generation_config if generation_config else None, + ) + except Exception as e: + raise LLMError( + f"Failed to create Gemini model: {e}", + provider="gemini", + model=model, + ) from e + + # Build content from messages + contents = self._build_sdk_contents(request) + + # Generate + try: + response = genai_model.generate_content(contents) + except Exception as e: + error_str = str(e).lower() + if "rate" in error_str or "quota" in error_str: + raise LLMRateLimitError(str(e), provider="gemini", model=model) from e + if "auth" in error_str or "key" in error_str or "permission" in error_str: + raise LLMAuthError(str(e), provider="gemini", model=model) from e + raise LLMError(str(e), provider="gemini", model=model) from e + + # Extract text + try: + text = response.text + except ValueError as e: + # Response was blocked or empty + raise LLMInvalidResponseError( + f"Gemini response was blocked or empty: {e}", + provider="gemini", + model=model, + ) from e + + # Extract usage if available + usage = None + if hasattr(response, "usage_metadata") and response.usage_metadata: + metadata = response.usage_metadata + usage = LLMUsage( + input_tokens=getattr(metadata, "prompt_token_count", None), + output_tokens=getattr(metadata, "candidates_token_count", None), + total_tokens=getattr(metadata, "total_token_count", None), + ) + + return LLMResponse( + text=text, + provider="gemini", + model=model, + usage=usage, + ) + + def _generate_with_http(self, request: LLMRequest, model: str) -> LLMResponse: + """Generate using direct HTTP requests. + + This is a fallback when the SDK is not available. + + Args: + request: The LLM request. + model: The model to use. + + Returns: + The LLM response. + """ + import urllib.error + import urllib.request + + # Build API URL + url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={self._api_key}" + + # Build request body + contents = self._build_http_contents(request) + body: dict[str, Any] = {"contents": contents} + + # Add generation config + generation_config: dict[str, Any] = {} + if request.temperature is not None: + generation_config["temperature"] = request.temperature + if request.max_output_tokens is not None: + generation_config["maxOutputTokens"] = request.max_output_tokens + if request.response_format == "json": + generation_config["responseMimeType"] = "application/json" + + if generation_config: + body["generationConfig"] = generation_config + + # Make request + req = urllib.request.Request( + url, + data=json.dumps(body).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + + try: + with urllib.request.urlopen(req, timeout=120) as resp: + response_data = json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + error_body = e.read().decode("utf-8") if e.fp else "" + if e.code == 429: + raise LLMRateLimitError( + f"Rate limit exceeded: {error_body}", + provider="gemini", + model=model, + ) from e + if e.code in (401, 403): + raise LLMAuthError( + f"Authentication failed: {error_body}", + provider="gemini", + model=model, + ) from e + raise LLMError( + f"HTTP error {e.code}: {error_body}", + provider="gemini", + model=model, + ) from e + except urllib.error.URLError as e: + raise LLMConnectionError( + f"Connection failed: {e}", + provider="gemini", + model=model, + ) from e + + # Extract text from response + try: + candidates = response_data.get("candidates", []) + if not candidates: + raise LLMInvalidResponseError( + "No candidates in response", + provider="gemini", + model=model, + ) + content = candidates[0].get("content", {}) + parts = content.get("parts", []) + if not parts: + raise LLMInvalidResponseError( + "No parts in response content", + provider="gemini", + model=model, + ) + text = parts[0].get("text", "") + except (KeyError, IndexError) as e: + raise LLMInvalidResponseError( + f"Failed to parse response: {e}", + provider="gemini", + model=model, + ) from e + + # Extract usage + usage = None + usage_metadata = response_data.get("usageMetadata", {}) + if usage_metadata: + usage = LLMUsage( + input_tokens=usage_metadata.get("promptTokenCount"), + output_tokens=usage_metadata.get("candidatesTokenCount"), + total_tokens=usage_metadata.get("totalTokenCount"), + ) + + return LLMResponse( + text=text, + provider="gemini", + model=model, + usage=usage, + ) + + def _build_sdk_contents(self, request: LLMRequest) -> list[Any]: + """Build SDK-compatible content list from messages. + + Args: + request: The LLM request. + + Returns: + Content list for SDK. + """ + contents: list[Any] = [] + + # Combine system and user messages + combined_prompt = "" + for msg in request.messages: + if msg.role == "system": + combined_prompt += msg.content + "\n\n" + elif msg.role == "user": + combined_prompt += msg.content + elif msg.role == "assistant": + # For multi-turn, include assistant responses + if combined_prompt: + contents.append({"role": "user", "parts": [{"text": combined_prompt}]}) + combined_prompt = "" + contents.append({"role": "model", "parts": [{"text": msg.content}]}) + + if combined_prompt: + contents.append({"role": "user", "parts": [{"text": combined_prompt}]}) + + return contents + + def _build_http_contents(self, request: LLMRequest) -> list[dict[str, Any]]: + """Build HTTP API-compatible content list from messages. + + Args: + request: The LLM request. + + Returns: + Content list for HTTP API. + """ + contents: list[dict[str, Any]] = [] + + # Combine system and user messages + combined_prompt = "" + for msg in request.messages: + if msg.role == "system": + combined_prompt += msg.content + "\n\n" + elif msg.role == "user": + combined_prompt += msg.content + elif msg.role == "assistant": + if combined_prompt: + contents.append({"role": "user", "parts": [{"text": combined_prompt}]}) + combined_prompt = "" + contents.append({"role": "model", "parts": [{"text": msg.content}]}) + + if combined_prompt: + contents.append({"role": "user", "parts": [{"text": combined_prompt}]}) + + return contents + + def _parse_json_response(self, text: str) -> dict[str, Any]: + """Parse JSON from response text. + + Handles cases where the model wraps JSON in markdown code blocks. + + Args: + text: The response text. + + Returns: + Parsed JSON dictionary. + + Raises: + LLMInvalidResponseError: If JSON parsing fails. + """ + # Try direct parse first + try: + return json.loads(text) # type: ignore[no-any-return] + except json.JSONDecodeError: + pass + + # Try to extract from markdown code block + text = text.strip() + if text.startswith("```"): + # Remove opening fence (with optional language) + lines = text.split("\n", 1) + if len(lines) > 1: + text = lines[1] + # Remove closing fence + if text.endswith("```"): + text = text[:-3] + text = text.strip() + + try: + return json.loads(text) # type: ignore[no-any-return] + except json.JSONDecodeError as e: + raise LLMInvalidResponseError( + f"Failed to parse JSON response: {e}. Text: {text[:100]}...", + provider="gemini", + model=self.model, + ) from e diff --git a/src/delibera/llm/prompts.py b/src/delibera/llm/prompts.py new file mode 100644 index 0000000..8e270c0 --- /dev/null +++ b/src/delibera/llm/prompts.py @@ -0,0 +1,134 @@ +"""Prompt helpers and JSON schema definitions for LLM agents. + +Provides shared prompt templates and JSON schema instructions for +structured LLM outputs in Delibera. +""" + +from typing import Any + +# JSON schema for Proposer output +PROPOSER_JSON_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "recommendation": { + "type": "string", + "description": "A concise recommendation for this option", + }, + "rationale": { + "type": "array", + "items": {"type": "string"}, + "description": "Key reasons supporting this recommendation (max 6)", + "maxItems": 6, + }, + "claims": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["fact", "inference", "plan", "value"], + "description": "The type of claim", + }, + "text": { + "type": "string", + "description": "The claim text", + }, + }, + "required": ["type", "text"], + }, + "description": "Claims made in this proposal (max 8)", + "maxItems": 8, + }, + "confidence": { + "type": "number", + "minimum": 0.0, + "maximum": 1.0, + "description": "Confidence in this recommendation (0.0 to 1.0)", + }, + }, + "required": ["recommendation", "rationale", "claims", "confidence"], +} + + +def build_proposer_system_prompt() -> str: + """Build the system prompt for the Proposer agent. + + Returns: + The system prompt string. + """ + return """You are a proposal generator for a structured deliberation system. + +Your task is to analyze an option and produce a structured proposal. + +RULES: +1. Output ONLY valid JSON. No markdown, no explanations, no code blocks. +2. Follow the exact schema provided. +3. Be specific and actionable in your recommendation. +4. Provide clear, evidence-based rationale. +5. Classify claims correctly: + - fact: Verifiable statements about the world + - inference: Logical conclusions from facts + - plan: Proposed actions or steps + - value: Judgments about importance or quality +6. Keep rationale concise (max 6 items). +7. Keep claims focused (max 8 items). +8. Confidence should reflect certainty (0.0-1.0). + +OUTPUT FORMAT (JSON only): +{ + "recommendation": "string", + "rationale": ["string", ...], + "claims": [{"type": "fact|inference|plan|value", "text": "string"}, ...], + "confidence": 0.0-1.0 +}""" + + +def build_proposer_user_prompt( + question: str, + option_label: str, + evidence_snippets: list[str] | None = None, + constraints: list[str] | None = None, +) -> str: + """Build the user prompt for the Proposer agent. + + Args: + question: The deliberation question. + option_label: The label of the option being proposed. + evidence_snippets: Optional evidence excerpts to consider. + constraints: Optional constraints from user. + + Returns: + The user prompt string. + """ + parts = [ + f"QUESTION: {question}", + f"\nOPTION: {option_label}", + ] + + if constraints: + parts.append("\nCONSTRAINTS:") + for c in constraints: + parts.append(f"- {c}") + + if evidence_snippets: + parts.append("\nAVAILABLE EVIDENCE:") + for i, snippet in enumerate(evidence_snippets[:5], 1): # Limit to 5 + # Truncate long snippets + truncated = snippet[:300] + "..." if len(snippet) > 300 else snippet + parts.append(f"{i}. {truncated}") + + parts.append("\nGenerate a structured proposal for this option. Output JSON only.") + + return "\n".join(parts) + + +def get_proposer_schema_string() -> str: + """Get the JSON schema as a formatted string for prompt inclusion. + + Returns: + JSON schema as string. + """ + import json + + return json.dumps(PROPOSER_JSON_SCHEMA, indent=2) diff --git a/src/delibera/llm/redaction.py b/src/delibera/llm/redaction.py new file mode 100644 index 0000000..ea9cc21 --- /dev/null +++ b/src/delibera/llm/redaction.py @@ -0,0 +1,155 @@ +"""Redaction helpers for LLM trace logging. + +Provides utilities to redact sensitive information from prompts and responses +before logging to traces. This ensures privacy while maintaining auditability. +""" + +import re +from typing import Any + +from delibera.llm.base import LLMMessage, LLMRequest, LLMResponse + +# Patterns for redaction (compiled for performance) +_EMAIL_PATTERN = re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}") +_API_KEY_PATTERN = re.compile( + r"(?:api[_-]?key|token|secret|password)[=:\s]+['\"]?[\w-]{16,}['\"]?", re.IGNORECASE +) +_LONG_NUMBER_PATTERN = re.compile(r"\b\d{10,}\b") +_FILE_PATH_PATTERN = re.compile(r"(?:/[a-zA-Z0-9._-]+){3,}") +_URL_PATTERN = re.compile(r"https?://[^\s<>\"]+") + +# Redaction placeholder +REDACTED = "[REDACTED]" + + +def redact_text(text: str) -> str: + """Redact sensitive information from text. + + Removes: + - Email addresses + - API keys and secrets + - Long numbers (10+ digits) + - File paths (3+ segments) + - URLs + + Args: + text: The text to redact. + + Returns: + The redacted text. + """ + result = text + result = _EMAIL_PATTERN.sub(REDACTED, result) + result = _API_KEY_PATTERN.sub(REDACTED, result) + result = _LONG_NUMBER_PATTERN.sub(REDACTED, result) + result = _FILE_PATH_PATTERN.sub(REDACTED, result) + result = _URL_PATTERN.sub(REDACTED, result) + return result + + +def summarize_messages(messages: list[LLMMessage]) -> dict[str, Any]: + """Summarize messages for trace logging without storing full content. + + Args: + messages: The messages to summarize. + + Returns: + A summary dict with: + - message_count: Total number of messages + - chars_by_role: Character counts per role + - user_preview: First 80 chars of user message (redacted) + """ + chars_by_role: dict[str, int] = {"system": 0, "user": 0, "assistant": 0} + user_preview = "" + + for msg in messages: + chars_by_role[msg.role] += len(msg.content) + if msg.role == "user" and not user_preview: + redacted = redact_text(msg.content) + user_preview = redacted[:80] + "..." if len(redacted) > 80 else redacted + + return { + "message_count": len(messages), + "chars_by_role": chars_by_role, + "user_preview": user_preview, + } + + +def create_llm_request_trace(request: LLMRequest) -> dict[str, Any]: + """Create a trace-safe representation of an LLM request. + + Does NOT include full prompt content. Only includes: + - Model and parameters + - Message summary + - Metadata + + Args: + request: The LLM request to trace. + + Returns: + A dict safe for trace logging. + """ + return { + "model": request.model, + "temperature": request.temperature, + "max_output_tokens": request.max_output_tokens, + "response_format": request.response_format, + "message_summary": summarize_messages(request.messages), + "metadata": request.metadata, + } + + +def create_llm_response_trace(response: LLMResponse) -> dict[str, Any]: + """Create a trace-safe representation of an LLM response. + + Includes: + - Provider and model info + - Usage statistics + - Latency + - Output length + - JSON keys if parsed (not values) + + Args: + response: The LLM response to trace. + + Returns: + A dict safe for trace logging. + """ + result: dict[str, Any] = { + "provider": response.provider, + "model": response.model, + "output_length": len(response.text), + "latency_ms": response.latency_ms, + } + + if response.usage: + result["usage"] = response.usage.to_dict() + + if response.parsed_json is not None: + # Only include keys, not values + result["json_keys"] = list(response.parsed_json.keys()) + + return result + + +def create_llm_error_trace( + error: Exception, + provider: str = "", + model: str = "", +) -> dict[str, Any]: + """Create a trace-safe representation of an LLM error. + + Args: + error: The exception that occurred. + provider: The LLM provider. + model: The model being used. + + Returns: + A dict safe for trace logging. + """ + return { + "provider": provider, + "model": model, + "error_type": type(error).__name__, + "error_message": redact_text(str(error)), + } diff --git a/src/delibera/protocol/__init__.py b/src/delibera/protocol/__init__.py index 26a7d48..90bed7e 100644 --- a/src/delibera/protocol/__init__.py +++ b/src/delibera/protocol/__init__.py @@ -30,6 +30,7 @@ ReduceSpec, StepSpec, validate_protocol, + warnings_for_protocol, ) __all__ = [ @@ -41,6 +42,7 @@ "ReduceSpec", "ConvergenceSpec", "validate_protocol", + "warnings_for_protocol", # Defaults "DEFAULT_PROTOCOL", "create_simple_protocol", diff --git a/src/delibera/protocol/interpreter.py b/src/delibera/protocol/interpreter.py index 51b0ba2..33d65a0 100644 --- a/src/delibera/protocol/interpreter.py +++ b/src/delibera/protocol/interpreter.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from typing import Any -from delibera.protocol.spec import ExpandSpec, ProtocolSpec, StepSpec +from delibera.protocol.spec import ConvergenceSpec, ExpandSpec, ProtocolSpec, StepSpec @dataclass @@ -190,3 +190,41 @@ def max_depth(self) -> int: The max_depth from the protocol. """ return self.spec.max_depth + + def has_refine_loop(self) -> bool: + """Check if the protocol has a refinement loop. + + Returns: + True if refine_loop is non-empty and max_rounds > 0. + """ + return len(self.spec.refine_loop) > 0 and self.spec.convergence.max_rounds > 0 + + def get_refine_loop_steps(self) -> list[StepSpec]: + """Get the refinement loop steps. + + Returns: + List of StepSpec for refinement. + """ + return self.spec.refine_loop + + def get_convergence_spec(self) -> ConvergenceSpec: + """Get the convergence specification. + + Returns: + The ConvergenceSpec from the protocol. + """ + return self.spec.convergence + + def get_refine_step_by_id(self, step_id: str) -> StepSpec | None: + """Get a refinement step by ID. + + Args: + step_id: The step ID to find. + + Returns: + StepSpec if found in refine_loop, None otherwise. + """ + for step in self.spec.refine_loop: + if step.id == step_id: + return step + return None diff --git a/src/delibera/protocol/loader.py b/src/delibera/protocol/loader.py index 498137f..05880ea 100644 --- a/src/delibera/protocol/loader.py +++ b/src/delibera/protocol/loader.py @@ -306,15 +306,27 @@ def _parse_reduce_spec(data: dict[str, Any], path: str) -> ReduceSpec: def _parse_convergence_spec(data: dict[str, Any], path: str) -> ConvergenceSpec: """Parse a convergence specification from a dictionary.""" - known_keys = {"max_rounds"} + known_keys = {"max_rounds", "weak_threshold", "score_epsilon"} _check_unknown_keys(data, known_keys, path) max_rounds = data.get("max_rounds", 0) if not isinstance(max_rounds, int): raise ProtocolLoadError("Expected integer", f"{path}.max_rounds") + weak_threshold = data.get("weak_threshold", 5) + if not isinstance(weak_threshold, int): + raise ProtocolLoadError("Expected integer", f"{path}.weak_threshold") + + score_epsilon = data.get("score_epsilon", 0.01) + if not isinstance(score_epsilon, (int, float)): + raise ProtocolLoadError("Expected number", f"{path}.score_epsilon") + try: - return ConvergenceSpec(max_rounds=max_rounds) + return ConvergenceSpec( + max_rounds=max_rounds, + weak_threshold=weak_threshold, + score_epsilon=float(score_epsilon), + ) except ValueError as e: raise ProtocolLoadError(str(e), path) from e diff --git a/src/delibera/protocol/spec.py b/src/delibera/protocol/spec.py index e216235..58f5b12 100644 --- a/src/delibera/protocol/spec.py +++ b/src/delibera/protocol/spec.py @@ -74,14 +74,27 @@ class ReduceSpec: @dataclass class ConvergenceSpec: - """Specification for convergence behavior.""" + """Specification for convergence behavior. + + Convergence predicates (all must be True to converge early): + 1. no_blocking_objections: No open blocking objections remain + 2. unsupported_claims == 0: All claims are supported or weak + 3. weak_claims <= weak_threshold: Number of weak claims is acceptable + 4. score_improvement < score_epsilon: Score delta between rounds is minimal + """ max_rounds: int = 0 # For refine loop; 0 means no refinement + weak_threshold: int = 5 # Maximum acceptable weak claims for convergence + score_epsilon: float = 0.01 # Minimum score improvement to continue refining def __post_init__(self) -> None: """Validate convergence specification.""" if self.max_rounds < 0: raise ValueError(f"max_rounds must be >= 0, got {self.max_rounds}") + if self.weak_threshold < 0: + raise ValueError(f"weak_threshold must be >= 0, got {self.weak_threshold}") + if self.score_epsilon < 0: + raise ValueError(f"score_epsilon must be >= 0, got {self.score_epsilon}") @dataclass @@ -192,4 +205,48 @@ def validate_protocol(spec: ProtocolSpec) -> list[str]: if not spec.branch_pipeline: errors.append("branch_pipeline cannot be empty") + # Check expand rule depth vs max_depth + for expand in spec.expand_rules: + if expand.depth > spec.max_depth: + errors.append( + f"Expand rule '{expand.id}' has depth {expand.depth} " + f"but max_depth is {spec.max_depth}" + ) + + # Check refine_loop without convergence.max_rounds (warn-level, not error) + # This is a warning, so we don't add it to errors list but the caller + # can detect this via warnings_for_protocol() + return errors + + +def warnings_for_protocol(spec: ProtocolSpec) -> list[str]: + """Return warnings for a protocol specification. + + These are not blocking errors, but indicate potential issues. + + Args: + spec: The protocol specification to check. + + Returns: + List of warning messages (empty if none). + """ + warnings: list[str] = [] + + # Warn if refine_loop is defined but max_rounds is 0 + if spec.refine_loop and spec.convergence.max_rounds == 0: + warnings.append( + "refine_loop is defined but convergence.max_rounds is 0; " + "refinement loop will never execute" + ) + + # Warn if keep_k > expand max_children (makes pruning pointless) + for expand in spec.expand_rules: + if spec.prune.keep_k >= expand.max_children: + warnings.append( + f"prune.keep_k ({spec.prune.keep_k}) >= " + f"expand rule '{expand.id}' max_children ({expand.max_children}); " + f"pruning will keep all children" + ) + + return warnings diff --git a/src/delibera/tools/builtin/calculator.py b/src/delibera/tools/builtin/calculator.py index 363a23c..71456cf 100644 --- a/src/delibera/tools/builtin/calculator.py +++ b/src/delibera/tools/builtin/calculator.py @@ -9,7 +9,7 @@ from collections.abc import Callable from typing import Any -from delibera.tools.spec import RiskLevel, ToolExecutionError +from delibera.tools.spec import RiskLevel, ToolCapability, ToolExecutionError # Type aliases for operator functions BinaryOpFunc = Callable[[float | int, float | int], float | int] @@ -54,10 +54,15 @@ def risk_level(self) -> RiskLevel: """Risk classification of this tool.""" return RiskLevel.LOW + @property + def capability(self) -> ToolCapability: + """Calculator is a compute tool.""" + return ToolCapability.COMPUTE + @property def is_discovery(self) -> bool: - """Calculator does not discover new information.""" - return False + """Whether this tool discovers new information.""" + return self.capability == ToolCapability.DISCOVERY def validate_input(self, tool_input: dict[str, Any]) -> None: """Validate the input before execution. diff --git a/src/delibera/tools/builtin/docs.py b/src/delibera/tools/builtin/docs.py index 6404f35..b1d8c99 100644 --- a/src/delibera/tools/builtin/docs.py +++ b/src/delibera/tools/builtin/docs.py @@ -1,20 +1,29 @@ -"""Local docs tool for reading evidence files. +"""Local docs tools for reading and searching evidence files. -This tool reads files from an allowlisted directory (evidence/) -to support claim validation with local evidence. +This module provides tools for working with local evidence packs: +- docs.read: Read files from an allowlisted directory +- docs.search: Search for relevant files/snippets within an evidence pack + +Both tools operate within a configured evidence_root directory (evidence pack). """ from pathlib import Path from typing import Any -from delibera.tools.spec import RiskLevel, ToolExecutionError +from delibera.tools.spec import RiskLevel, ToolCapability, ToolExecutionError # Maximum file size to read (1MB) MAX_FILE_SIZE = 1024 * 1024 +# Maximum number of files to scan during search +MAX_SEARCH_FILES = 200 + # Default evidence root directory (relative to repo root) DEFAULT_EVIDENCE_ROOT = Path("evidence") +# Maximum snippet length for search results +MAX_SNIPPET_LENGTH = 200 + class DocsReadTool: """Tool for reading local documentation/evidence files. @@ -26,6 +35,9 @@ class DocsReadTool: - Maximum file size limit - UTF-8 encoding with error replacement + Capability: inspect (not discovery) + During CLAIM_CHECK, access is restricted to already-cited sources. + Input: {"path": "evidence/file.txt"} Output: {"text": "...file contents..."} """ @@ -50,14 +62,19 @@ def risk_level(self) -> RiskLevel: return RiskLevel.LOW @property - def is_discovery(self) -> bool: - """docs.read is NOT a discovery tool. + def capability(self) -> ToolCapability: + """docs.read is an inspect tool. It reads from a fixed local directory, not searching for new sources. - However, during CLAIM_CHECK, it should only be allowed for - already-cited evidence sources (enforced by policy). + During CLAIM_CHECK, it should only be allowed for already-cited + evidence sources (enforced by policy). """ - return False + return ToolCapability.INSPECT + + @property + def is_discovery(self) -> bool: + """Whether this tool discovers new information.""" + return self.capability == ToolCapability.DISCOVERY def validate_input(self, tool_input: dict[str, Any]) -> None: """Validate the input before execution. @@ -171,3 +188,239 @@ def _resolve_path(self, relative_path: str) -> Path: return path else: return self._evidence_root / path + + +class DocsSearchTool: + """Tool for searching local documentation/evidence files. + + Searches within an evidence pack (allowlisted directory) for files + containing query terms. Returns deterministic results with snippets. + + Capability: discovery + This tool is DENIED during CLAIM_CHECK to enforce evidence-local restriction. + + Scoring: + - Count of query token matches in file text (case-insensitive) + - Ties broken by path lexicographic order + + Input: {"query": "search terms", "max_results": 5} + Output: {"results": [{"path": "...", "score": 1.0, "snippet": "..."}, ...]} + """ + + def __init__(self, evidence_root: Path | None = None) -> None: + """Initialize the docs.search tool. + + Args: + evidence_root: Root directory for evidence files. + Defaults to ./evidence relative to current working directory. + """ + self._evidence_root = evidence_root or DEFAULT_EVIDENCE_ROOT + + @property + def name(self) -> str: + """Unique identifier for the tool.""" + return "docs.search" + + @property + def risk_level(self) -> RiskLevel: + """Risk classification of this tool.""" + return RiskLevel.LOW + + @property + def capability(self) -> ToolCapability: + """docs.search is a discovery tool. + + It searches for new information, so it is denied during CLAIM_CHECK. + """ + return ToolCapability.DISCOVERY + + @property + def is_discovery(self) -> bool: + """Whether this tool discovers new information.""" + return self.capability == ToolCapability.DISCOVERY + + def validate_input(self, tool_input: dict[str, Any]) -> None: + """Validate the input before execution. + + Args: + tool_input: The input dictionary to validate. + + Raises: + ValueError: If the input is invalid. + """ + if "query" not in tool_input: + raise ValueError("Missing required field: 'query'") + + query = tool_input["query"] + if not isinstance(query, str): + raise ValueError("Field 'query' must be a string") + + if not query.strip(): + raise ValueError("Field 'query' cannot be empty") + + max_results = tool_input.get("max_results", 5) + if not isinstance(max_results, int) or max_results < 1: + raise ValueError("Field 'max_results' must be a positive integer") + + def execute(self, tool_input: dict[str, Any]) -> dict[str, Any]: + """Search for files matching the query. + + Args: + tool_input: Dictionary with 'query' and optional 'max_results' keys. + + Returns: + Dictionary with 'results' list of matches. + + Raises: + ToolExecutionError: If search fails. + """ + self.validate_input(tool_input) + query = tool_input["query"] + max_results = tool_input.get("max_results", 5) + + # Check evidence root exists + if not self._evidence_root.exists(): + return {"results": [], "warning": "Evidence directory not found"} + + # Tokenize query (simple whitespace split, lowercase) + query_tokens = query.lower().split() + if not query_tokens: + return {"results": []} + + # Collect all text files in evidence root + results: list[dict[str, Any]] = [] + file_count = 0 + + for file_path in self._iter_text_files(): + file_count += 1 + if file_count > MAX_SEARCH_FILES: + break + + # Read and score the file + try: + text = file_path.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + + score = self._score_text(text, query_tokens) + if score > 0: + # Get relative path from evidence root + try: + rel_path = file_path.relative_to(self._evidence_root) + except ValueError: + rel_path = file_path + + # Extract snippet around first match + snippet = self._extract_snippet(text, query_tokens) + + results.append( + { + "path": str(rel_path), + "score": score, + "snippet": snippet, + } + ) + + # Sort by score (descending) then path (ascending) for determinism + results.sort(key=lambda r: (-r["score"], r["path"])) + + # Limit results + results = results[:max_results] + + output: dict[str, Any] = {"results": results} + if file_count > MAX_SEARCH_FILES: + output["warning"] = f"Search limited to {MAX_SEARCH_FILES} files" + + return output + + def _iter_text_files(self) -> "list[Path]": + """Iterate over text files in evidence root. + + Returns: + Sorted list of text file paths (sorted for determinism). + """ + if not self._evidence_root.is_dir(): + return [] + + # Collect files and sort for deterministic ordering + files: list[Path] = [] + for path in self._evidence_root.rglob("*"): + if not path.is_file(): + continue + if path.is_symlink(): + continue + # Skip files that are too large + try: + if path.stat().st_size > MAX_FILE_SIZE: + continue + except OSError: + continue + # Check for text-like extensions or no extension + suffix = path.suffix.lower() + if suffix in ("", ".txt", ".md", ".rst", ".json", ".yaml", ".yml", ".csv"): + files.append(path) + + # Sort for determinism + files.sort() + return files + + def _score_text(self, text: str, query_tokens: list[str]) -> int: + """Score text based on query token matches. + + Args: + text: The file content. + query_tokens: Lowercased query tokens. + + Returns: + Total count of token matches in text. + """ + text_lower = text.lower() + score = 0 + for token in query_tokens: + # Count occurrences of each token + score += text_lower.count(token) + return score + + def _extract_snippet(self, text: str, query_tokens: list[str]) -> str: + """Extract a snippet around the first query match. + + Args: + text: The file content. + query_tokens: Lowercased query tokens. + + Returns: + Snippet string (max MAX_SNIPPET_LENGTH chars). + """ + text_lower = text.lower() + + # Find first occurrence of any token + first_pos = len(text) + for token in query_tokens: + pos = text_lower.find(token) + if pos != -1 and pos < first_pos: + first_pos = pos + + if first_pos == len(text): + # No match found, return start of text + return text[:MAX_SNIPPET_LENGTH].strip() + + # Extract context around the match + start = max(0, first_pos - 50) + end = min(len(text), first_pos + MAX_SNIPPET_LENGTH - 50) + + snippet = text[start:end] + + # Clean up boundaries + if start > 0: + # Trim to word boundary + space_pos = snippet.find(" ") + if 0 < space_pos < 20: + snippet = "..." + snippet[space_pos + 1 :] + + if end < len(text): + # Trim to word boundary + space_pos = snippet.rfind(" ") + if space_pos > len(snippet) - 20: + snippet = snippet[:space_pos] + "..." + + return snippet.strip() diff --git a/src/delibera/tools/policy.py b/src/delibera/tools/policy.py index 30687e4..7add55e 100644 --- a/src/delibera/tools/policy.py +++ b/src/delibera/tools/policy.py @@ -279,7 +279,7 @@ def create_default_policy_engine() -> PolicyEngine: PolicyEngine with all built-in tools enabled. """ global_policy = GlobalPolicy( - enabled_tools={"calculator", "docs.read"}, # Enable built-in tools + enabled_tools={"calculator", "docs.read", "docs.search"}, # Enable built-in tools max_calls=100, # Default budget ) return PolicyEngine(global_policy=global_policy) diff --git a/src/delibera/tools/registry.py b/src/delibera/tools/registry.py index 7a5c06b..d801907 100644 --- a/src/delibera/tools/registry.py +++ b/src/delibera/tools/registry.py @@ -108,9 +108,10 @@ def create_default_registry(evidence_root: Any = None) -> ToolRegistry: A ToolRegistry pre-populated with built-in tools. """ from delibera.tools.builtin.calculator import CalculatorTool - from delibera.tools.builtin.docs import DocsReadTool + from delibera.tools.builtin.docs import DocsReadTool, DocsSearchTool registry = ToolRegistry() registry.register(CalculatorTool()) registry.register(DocsReadTool(evidence_root=evidence_root)) + registry.register(DocsSearchTool(evidence_root=evidence_root)) return registry diff --git a/src/delibera/tools/spec.py b/src/delibera/tools/spec.py index ace7ca8..6804152 100644 --- a/src/delibera/tools/spec.py +++ b/src/delibera/tools/spec.py @@ -1,7 +1,7 @@ """Tool specification and schema definitions. Defines the ToolSpec protocol that all tools must implement, -and the RiskLevel enum for tool classification. +and the RiskLevel and ToolCapability enums for tool classification. """ from abc import abstractmethod @@ -22,6 +22,21 @@ class RiskLevel(Enum): HIGH = "high" +class ToolCapability(Enum): + """Capability classification for tools. + + discovery: Tools that find/search for new information (e.g., docs.search) + inspect: Tools that read already-known sources (e.g., docs.read) + compute: Tools that perform computation without external data (e.g., calculator) + + Discovery tools are denied during CLAIM_CHECK (evidence-local restriction). + """ + + DISCOVERY = "discovery" + INSPECT = "inspect" + COMPUTE = "compute" + + class ToolSpec(Protocol): """Protocol defining the tool interface. @@ -41,14 +56,24 @@ def risk_level(self) -> RiskLevel: """Risk classification of this tool.""" ... + @property + def capability(self) -> ToolCapability: + """Capability classification of this tool. + + Defaults to COMPUTE. Override in subclasses. + """ + return ToolCapability.COMPUTE + @property def is_discovery(self) -> bool: """Whether this tool discovers new information. Discovery tools are denied during CLAIM_CHECK step to enforce evidence-local restriction. + + This is derived from capability for backwards compatibility. """ - return False + return self.capability == ToolCapability.DISCOVERY def validate_input(self, tool_input: dict[str, Any]) -> None: """Validate the input before execution. diff --git a/tests/inspect/__init__.py b/tests/inspect/__init__.py new file mode 100644 index 0000000..eeb8fe5 --- /dev/null +++ b/tests/inspect/__init__.py @@ -0,0 +1 @@ +"""Tests for the inspect module.""" diff --git a/tests/inspect/test_inspect.py b/tests/inspect/test_inspect.py new file mode 100644 index 0000000..3bc2b5f --- /dev/null +++ b/tests/inspect/test_inspect.py @@ -0,0 +1,347 @@ +"""Tests for the inspect module and CLI commands.""" + +import tempfile +from pathlib import Path + +import pytest + +from delibera.engine.orchestrator import Engine +from delibera.inspect import build_run_summary, render_markdown, render_text + + +class TestInspectOutputsSections: + """Test that inspect output contains required sections.""" + + def test_inspect_has_final_recommendation(self) -> None: + """Test that inspect output includes Final Recommendation section.""" + with tempfile.TemporaryDirectory() as tmpdir: + runs_dir = Path(tmpdir) + engine = Engine(runs_dir=runs_dir) + + run_dir = engine.run("Should we adopt uv?") + + summary = build_run_summary(run_dir) + output = render_text(summary) + + assert "Final Recommendation" in output + + def test_inspect_has_selected_path(self) -> None: + """Test that inspect output includes Selected Path section.""" + with tempfile.TemporaryDirectory() as tmpdir: + runs_dir = Path(tmpdir) + engine = Engine(runs_dir=runs_dir) + + run_dir = engine.run("Should we adopt uv?") + + summary = build_run_summary(run_dir) + output = render_text(summary) + + assert "Selected Path" in output + + def test_inspect_has_claims_evidence_sections(self) -> None: + """Test that inspect output includes Claims and Evidence info.""" + with tempfile.TemporaryDirectory() as tmpdir: + runs_dir = Path(tmpdir) + engine = Engine(runs_dir=runs_dir) + + run_dir = engine.run("Should we adopt uv?") + + summary = build_run_summary(run_dir) + output = render_text(summary) + + # Should have claims info + assert "Claims" in output or "supported" in output.lower() + # Should have evidence info + assert "Evidence" in output or "evidence" in output.lower() + + def test_inspect_has_pruning_section(self) -> None: + """Test that inspect output includes Pruning section.""" + with tempfile.TemporaryDirectory() as tmpdir: + runs_dir = Path(tmpdir) + engine = Engine(runs_dir=runs_dir) + + run_dir = engine.run("Should we adopt uv?") + + summary = build_run_summary(run_dir) + output = render_text(summary) + + assert "Pruning" in output + + def test_inspect_has_statistics(self) -> None: + """Test that inspect output includes Statistics section.""" + with tempfile.TemporaryDirectory() as tmpdir: + runs_dir = Path(tmpdir) + engine = Engine(runs_dir=runs_dir) + + run_dir = engine.run("Should we adopt uv?") + + summary = build_run_summary(run_dir) + output = render_text(summary) + + assert "Statistics" in output + assert "Total nodes" in output + assert "Tool calls" in output + + def test_inspect_shows_question(self) -> None: + """Test that inspect output shows the original question.""" + with tempfile.TemporaryDirectory() as tmpdir: + runs_dir = Path(tmpdir) + engine = Engine(runs_dir=runs_dir) + + question = "Should we adopt uv?" + run_dir = engine.run(question) + + summary = build_run_summary(run_dir) + output = render_text(summary) + + assert question in output + + +class TestReportDeterministic: + """Test that report generation is deterministic.""" + + def test_report_md_deterministic(self) -> None: + """Test that generating markdown report twice produces identical output.""" + with tempfile.TemporaryDirectory() as tmpdir: + runs_dir = Path(tmpdir) + engine = Engine(runs_dir=runs_dir) + + run_dir = engine.run("Should we adopt uv?") + + # Generate report twice + summary1 = build_run_summary(run_dir) + report1 = render_markdown(summary1) + + summary2 = build_run_summary(run_dir) + report2 = render_markdown(summary2) + + # Should be identical + assert report1 == report2 + + def test_text_output_deterministic(self) -> None: + """Test that text rendering is deterministic.""" + with tempfile.TemporaryDirectory() as tmpdir: + runs_dir = Path(tmpdir) + engine = Engine(runs_dir=runs_dir) + + run_dir = engine.run("Should we adopt uv?") + + # Render twice + summary1 = build_run_summary(run_dir) + text1 = render_text(summary1) + + summary2 = build_run_summary(run_dir) + text2 = render_text(summary2) + + # Should be identical + assert text1 == text2 + + +class TestInspectReadOnly: + """Test that inspect/report does not create new runs.""" + + def test_inspect_does_not_create_run_directories(self) -> None: + """Test that inspecting a run does not create any new run directories.""" + with tempfile.TemporaryDirectory() as tmpdir: + runs_dir = Path(tmpdir) + engine = Engine(runs_dir=runs_dir) + + # Create one run + run_dir = engine.run("Should we adopt uv?") + + # Count directories before inspect + dirs_before = list(runs_dir.iterdir()) + + # Inspect the run + summary = build_run_summary(run_dir) + _ = render_text(summary) + + # Count directories after inspect + dirs_after = list(runs_dir.iterdir()) + + # Should be the same count + assert len(dirs_before) == len(dirs_after) + assert set(dirs_before) == set(dirs_after) + + def test_report_does_not_create_run_directories(self) -> None: + """Test that generating a report does not create any new run directories.""" + with tempfile.TemporaryDirectory() as tmpdir: + runs_dir = Path(tmpdir) + engine = Engine(runs_dir=runs_dir) + + # Create one run + run_dir = engine.run("Should we adopt uv?") + + # Count directories before report + dirs_before = list(runs_dir.iterdir()) + + # Generate report + summary = build_run_summary(run_dir) + _ = render_markdown(summary) + + # Count directories after report + dirs_after = list(runs_dir.iterdir()) + + # Should be the same count + assert len(dirs_before) == len(dirs_after) + assert set(dirs_before) == set(dirs_after) + + +class TestRunSummaryStructure: + """Test RunSummary data structure.""" + + def test_summary_has_run_id(self) -> None: + """Test that summary includes run_id.""" + with tempfile.TemporaryDirectory() as tmpdir: + runs_dir = Path(tmpdir) + engine = Engine(runs_dir=runs_dir) + + run_dir = engine.run("Should we adopt uv?") + + summary = build_run_summary(run_dir) + + assert summary.run_id != "" + assert summary.run_id == run_dir.name + + def test_summary_has_question(self) -> None: + """Test that summary includes the question.""" + with tempfile.TemporaryDirectory() as tmpdir: + runs_dir = Path(tmpdir) + engine = Engine(runs_dir=runs_dir) + + question = "Should we adopt uv?" + run_dir = engine.run(question) + + summary = build_run_summary(run_dir) + + assert summary.question == question + + def test_summary_has_selected_path(self) -> None: + """Test that summary includes selected path with nodes.""" + with tempfile.TemporaryDirectory() as tmpdir: + runs_dir = Path(tmpdir) + engine = Engine(runs_dir=runs_dir) + + run_dir = engine.run("Should we adopt uv?") + + summary = build_run_summary(run_dir) + + # Should have at least root node + assert len(summary.selected_path) >= 1 + + # First should be root + assert summary.selected_path[0].kind == "root" + + def test_summary_has_prune_events(self) -> None: + """Test that summary includes prune events.""" + with tempfile.TemporaryDirectory() as tmpdir: + runs_dir = Path(tmpdir) + engine = Engine(runs_dir=runs_dir) + + run_dir = engine.run("Should we adopt uv?") + + summary = build_run_summary(run_dir) + + # Should have at least one prune event + assert len(summary.prune_events) >= 1 + + # Prune event should have kept and pruned lists + prune = summary.prune_events[0] + assert len(prune.kept) >= 1 # At least 2 survivors + assert len(prune.pruned) >= 1 # At least 1 pruned + + def test_summary_has_total_stats(self) -> None: + """Test that summary includes aggregate statistics.""" + with tempfile.TemporaryDirectory() as tmpdir: + runs_dir = Path(tmpdir) + engine = Engine(runs_dir=runs_dir) + + run_dir = engine.run("Should we adopt uv?") + + summary = build_run_summary(run_dir) + + # Should have stats + assert summary.total_nodes > 0 + assert summary.total_tool_calls >= 0 + assert summary.total_evidence >= 0 + + +class TestMarkdownReport: + """Test markdown report content.""" + + def test_markdown_has_title(self) -> None: + """Test that markdown report has title.""" + with tempfile.TemporaryDirectory() as tmpdir: + runs_dir = Path(tmpdir) + engine = Engine(runs_dir=runs_dir) + + run_dir = engine.run("Should we adopt uv?") + + summary = build_run_summary(run_dir) + report = render_markdown(summary) + + assert "# Deliberation Report" in report + + def test_markdown_has_overview_table(self) -> None: + """Test that markdown report has overview table.""" + with tempfile.TemporaryDirectory() as tmpdir: + runs_dir = Path(tmpdir) + engine = Engine(runs_dir=runs_dir) + + run_dir = engine.run("Should we adopt uv?") + + summary = build_run_summary(run_dir) + report = render_markdown(summary) + + assert "## Overview" in report + assert "| Field | Value |" in report + assert "| Run ID |" in report + + def test_markdown_has_statistics_table(self) -> None: + """Test that markdown report has statistics table.""" + with tempfile.TemporaryDirectory() as tmpdir: + runs_dir = Path(tmpdir) + engine = Engine(runs_dir=runs_dir) + + run_dir = engine.run("Should we adopt uv?") + + summary = build_run_summary(run_dir) + report = render_markdown(summary) + + assert "## Statistics" in report + assert "| Metric | Value |" in report + assert "| Total nodes |" in report + + def test_markdown_has_footer(self) -> None: + """Test that markdown report has footer.""" + with tempfile.TemporaryDirectory() as tmpdir: + runs_dir = Path(tmpdir) + engine = Engine(runs_dir=runs_dir) + + run_dir = engine.run("Should we adopt uv?") + + summary = build_run_summary(run_dir) + report = render_markdown(summary) + + assert "Generated by Delibera" in report + + +class TestInspectErrors: + """Test error handling in inspect.""" + + def test_missing_trace_raises(self) -> None: + """Test that missing trace.jsonl raises error.""" + with tempfile.TemporaryDirectory() as tmpdir: + run_dir = Path(tmpdir) / "fake_run" + run_dir.mkdir() + + with pytest.raises(FileNotFoundError): + build_run_summary(run_dir) + + def test_invalid_run_dir_raises(self) -> None: + """Test that invalid run directory raises error.""" + with tempfile.TemporaryDirectory() as tmpdir: + run_dir = Path(tmpdir) / "nonexistent" + + with pytest.raises(FileNotFoundError): + build_run_summary(run_dir) diff --git a/tests/llm/__init__.py b/tests/llm/__init__.py new file mode 100644 index 0000000..199253d --- /dev/null +++ b/tests/llm/__init__.py @@ -0,0 +1 @@ +"""Tests for LLM subsystem.""" diff --git a/tests/llm/test_llm_proposer.py b/tests/llm/test_llm_proposer.py new file mode 100644 index 0000000..b547124 --- /dev/null +++ b/tests/llm/test_llm_proposer.py @@ -0,0 +1,443 @@ +"""Tests for LLM proposer agent. + +These tests use a FakeLLMClient to avoid network calls and API key requirements. +""" + +import json +import tempfile +from pathlib import Path +from typing import Any + +import pytest + +from delibera.llm.base import LLMRequest, LLMResponse, LLMUsage + + +class FakeLLMClient: + """Fake LLM client for testing that returns configurable responses.""" + + def __init__( + self, + response_json: dict[str, Any] | None = None, + response_text: str | None = None, + raise_error: Exception | None = None, + ) -> None: + """Initialize fake client. + + Args: + response_json: JSON to return (will be serialized to text). + response_text: Raw text to return (overrides response_json). + raise_error: Exception to raise on generate. + """ + self.response_json = response_json + self.response_text = response_text + self.raise_error = raise_error + self.calls: list[LLMRequest] = [] + + def generate(self, request: LLMRequest) -> LLMResponse: + """Generate a fake response.""" + self.calls.append(request) + + if self.raise_error: + raise self.raise_error + + if self.response_text is not None: + text = self.response_text + elif self.response_json is not None: + text = json.dumps(self.response_json) + else: + text = '{"recommendation": "default", "rationale": [], "claims": [], "confidence": 0.5}' + + return LLMResponse( + text=text, + parsed_json=self.response_json, + usage=LLMUsage(input_tokens=100, output_tokens=50, total_tokens=150), + provider="fake", + model="fake-model", + latency_ms=10, + ) + + +class TestProposerLLMParsesJson: + """Test that ProposerLLM correctly parses LLM JSON responses.""" + + def test_parses_valid_json_response(self) -> None: + """ProposerLLM should parse valid JSON from LLM response.""" + from delibera.agents.llm_proposer import ProposerLLM + + response_json = { + "recommendation": "Use uv for faster dependency management", + "rationale": [ + "10-100x faster than pip", + "Drop-in replacement for pip commands", + "Generates reproducible lockfiles", + ], + "claims": [ + {"type": "fact", "text": "uv is 10-100x faster than pip"}, + {"type": "inference", "text": "Migration will be smooth"}, + ], + "confidence": 0.85, + } + + client = FakeLLMClient(response_json=response_json) + proposer = ProposerLLM(llm_client=client) + + context = { + "label": "Option A: Adopt uv", + "question": "Should we adopt uv for dependency management?", + "node_id": "node_123", + } + + output = proposer.execute(context) + + # Check output structure + assert output["proposal"] == "Use uv for faster dependency management" + assert output["recommendation"] == "Use uv for faster dependency management" + assert len(output["rationale"]) == 3 + assert output["confidence"] == 0.85 + assert output["llm_generated"] is True + assert "score" in output # Should have score from confidence + + def test_normalizes_invalid_confidence(self) -> None: + """ProposerLLM should clamp confidence to [0, 1].""" + from delibera.agents.llm_proposer import ProposerLLM + + # Test confidence > 1 + client = FakeLLMClient( + response_json={ + "recommendation": "Test", + "rationale": [], + "claims": [], + "confidence": 1.5, # Invalid - should be clamped + } + ) + proposer = ProposerLLM(llm_client=client) + output = proposer.execute({"label": "Test", "question": "Q", "node_id": "n1"}) + assert output["confidence"] == 1.0 + + # Test confidence < 0 + client = FakeLLMClient( + response_json={ + "recommendation": "Test", + "rationale": [], + "claims": [], + "confidence": -0.5, # Invalid - should be clamped + } + ) + proposer = ProposerLLM(llm_client=client) + output = proposer.execute({"label": "Test", "question": "Q", "node_id": "n2"}) + assert output["confidence"] == 0.0 + + def test_limits_rationale_to_max(self) -> None: + """ProposerLLM should limit rationale to 6 items.""" + from delibera.agents.llm_proposer import ProposerLLM + + client = FakeLLMClient( + response_json={ + "recommendation": "Test", + "rationale": ["R1", "R2", "R3", "R4", "R5", "R6", "R7", "R8"], # 8 items + "claims": [], + "confidence": 0.5, + } + ) + proposer = ProposerLLM(llm_client=client) + output = proposer.execute({"label": "Test", "question": "Q", "node_id": "n1"}) + assert len(output["rationale"]) == 6 + + def test_limits_claims_to_max(self) -> None: + """ProposerLLM should limit claims to 8 items.""" + from delibera.agents.llm_proposer import ProposerLLM + + claims = [{"type": "fact", "text": f"Claim {i}"} for i in range(10)] + client = FakeLLMClient( + response_json={ + "recommendation": "Test", + "rationale": [], + "claims": claims, # 10 claims + "confidence": 0.5, + } + ) + proposer = ProposerLLM(llm_client=client) + output = proposer.execute({"label": "Test", "question": "Q", "node_id": "n1"}) + assert len(output["claims"]) == 8 + + def test_handles_missing_fields_gracefully(self) -> None: + """ProposerLLM should provide defaults for missing fields.""" + from delibera.agents.llm_proposer import ProposerLLM + + # Empty response + client = FakeLLMClient(response_json={}) + proposer = ProposerLLM(llm_client=client) + output = proposer.execute({"label": "Option X", "question": "Q", "node_id": "n1"}) + + assert "recommendation" in output + assert "rationale" in output + assert "claims" in output + assert "confidence" in output + assert output["confidence"] == 0.5 # Default + + def test_extracts_facts_for_facts_field(self) -> None: + """ProposerLLM should extract fact claims for the 'facts' field.""" + from delibera.agents.llm_proposer import ProposerLLM + + client = FakeLLMClient( + response_json={ + "recommendation": "Test", + "rationale": [], + "claims": [ + {"type": "fact", "text": "Fact 1"}, + {"type": "inference", "text": "Inference 1"}, + {"type": "fact", "text": "Fact 2"}, + ], + "confidence": 0.5, + } + ) + proposer = ProposerLLM(llm_client=client) + output = proposer.execute({"label": "Test", "question": "Q", "node_id": "n1"}) + + assert output["facts"] == ["Fact 1", "Fact 2"] + + +class TestLLMTraceEventsEmitted: + """Test that LLM trace events are properly emitted during runs.""" + + def test_llm_call_events_emitted_with_fake_client(self) -> None: + """Run with LLM proposer should emit llm_call_requested and llm_call_succeeded.""" + from delibera.engine.orchestrator import Engine + + response_json = { + "recommendation": "Test recommendation", + "rationale": ["Reason 1"], + "claims": [{"type": "fact", "text": "Test fact"}], + "confidence": 0.7, + } + fake_client = FakeLLMClient(response_json=response_json) + + with tempfile.TemporaryDirectory() as tmpdir: + engine = Engine( + runs_dir=Path(tmpdir), + llm_client=fake_client, + use_llm_proposer=True, + gates_enabled=False, + ) + + run_dir = engine.run("Test question for LLM") + + # Read trace + trace_path = run_dir / "trace.jsonl" + events = [json.loads(line) for line in trace_path.read_text().splitlines()] + + # Check for LLM events + event_types = [e["event_type"] for e in events] + assert "llm_call_requested" in event_types + assert "llm_call_succeeded" in event_types + + # Check llm_call_requested content + llm_requested = [e for e in events if e["event_type"] == "llm_call_requested"] + assert len(llm_requested) >= 1 + assert llm_requested[0]["payload"]["role"] == "proposer" + assert llm_requested[0]["payload"]["step"] == "PROPOSE" + + def test_llm_call_failed_emitted_on_error(self) -> None: + """LLM errors should emit llm_call_failed and fall back to stub.""" + from delibera.engine.orchestrator import Engine + from delibera.llm.base import LLMError + + fake_client = FakeLLMClient(raise_error=LLMError("Test error")) + + with tempfile.TemporaryDirectory() as tmpdir: + engine = Engine( + runs_dir=Path(tmpdir), + llm_client=fake_client, + use_llm_proposer=True, + gates_enabled=False, + ) + + # Should still complete (falls back to stub) + run_dir = engine.run("Test question with failing LLM") + + # Read trace + trace_path = run_dir / "trace.jsonl" + events = [json.loads(line) for line in trace_path.read_text().splitlines()] + + # Check for failure event + event_types = [e["event_type"] for e in events] + assert "llm_call_failed" in event_types + + # Verify run still completed + assert (run_dir / "artifact.json").exists() + + +class TestLLMDisabledInClaimCheck: + """Test that LLM is not used during CLAIM_CHECK validation steps.""" + + def test_llm_not_called_in_claim_check(self) -> None: + """CLAIM_CHECK step should not invoke LLM.""" + from delibera.engine.orchestrator import Engine + + response_json = { + "recommendation": "Test", + "rationale": ["R1"], + "claims": [{"type": "fact", "text": "F1"}], + "confidence": 0.5, + } + fake_client = FakeLLMClient(response_json=response_json) + + with tempfile.TemporaryDirectory() as tmpdir: + engine = Engine( + runs_dir=Path(tmpdir), + llm_client=fake_client, + use_llm_proposer=True, + gates_enabled=False, + ) + + run_dir = engine.run("Test question") + + # Read trace + trace_path = run_dir / "trace.jsonl" + events = [json.loads(line) for line in trace_path.read_text().splitlines()] + + # Check that LLM was only called in PROPOSE, not CLAIM_CHECK + llm_calls = [ + e for e in events if e["event_type"] in ("llm_call_requested", "llm_call_succeeded") + ] + for call in llm_calls: + assert call["payload"]["step"] == "PROPOSE" + # Should never see CLAIM_CHECK or validate step + assert call["payload"]["step"] != "CLAIM_CHECK" + + def test_check_llm_allowed_raises_in_validate(self) -> None: + """check_llm_allowed_in_step should raise for validate steps.""" + from delibera.agents.llm_proposer import LLMNotAllowedError, check_llm_allowed_in_step + + # Work steps are allowed + check_llm_allowed_in_step("work") # Should not raise + + # Validate steps are not allowed + with pytest.raises(LLMNotAllowedError): + check_llm_allowed_in_step("validate") + + # Score steps are not allowed + with pytest.raises(LLMNotAllowedError): + check_llm_allowed_in_step("score") + + +class TestCLIRequiresApiKey: + """Test that CLI properly requires API key for LLM mode.""" + + def test_cli_fails_without_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None: + """CLI should fail with clear error when API key is missing.""" + import os + import subprocess + import sys + + # Ensure GEMINI_API_KEY is not set + monkeypatch.delenv("GEMINI_API_KEY", raising=False) + + # Build environment without GEMINI_API_KEY + env = {k: v for k, v in os.environ.items() if k != "GEMINI_API_KEY"} + + result = subprocess.run( + [ + sys.executable, + "-m", + "delibera.cli", + "run", + "--question", + "Test", + "--use-llm-proposer", + "--no-gates", + ], + capture_output=True, + text=True, + env=env, + ) + + # Should fail + assert result.returncode != 0 + # Should mention API key + assert "GEMINI_API_KEY" in result.stderr or "api" in result.stderr.lower() + + +class TestRedaction: + """Test redaction helpers for trace logging.""" + + def test_redact_email(self) -> None: + """redact_text should redact email addresses.""" + from delibera.llm.redaction import redact_text + + text = "Contact me at user@example.com for details" + redacted = redact_text(text) + assert "user@example.com" not in redacted + assert "[REDACTED]" in redacted + + def test_redact_api_key(self) -> None: + """redact_text should redact API keys.""" + from delibera.llm.redaction import redact_text + + text = "api_key=sk-1234567890abcdef1234567890abcdef" + redacted = redact_text(text) + assert "sk-1234567890" not in redacted + assert "[REDACTED]" in redacted + + def test_redact_long_numbers(self) -> None: + """redact_text should redact long numbers (10+ digits).""" + from delibera.llm.redaction import redact_text + + text = "Account number: 1234567890123456" + redacted = redact_text(text) + assert "1234567890123456" not in redacted + assert "[REDACTED]" in redacted + + def test_summarize_messages(self) -> None: + """summarize_messages should create safe summary without full content.""" + from delibera.llm.base import LLMMessage + from delibera.llm.redaction import summarize_messages + + messages = [ + LLMMessage(role="system", content="You are a helpful assistant."), + LLMMessage(role="user", content="Tell me about user@email.com"), + ] + + summary = summarize_messages(messages) + + assert summary["message_count"] == 2 + assert "system" in summary["chars_by_role"] + assert "user" in summary["chars_by_role"] + # User preview should be redacted + assert "user@email.com" not in summary["user_preview"] + + +class TestReplayIgnoresLLMEvents: + """Test that replay works correctly with LLM trace events.""" + + def test_replay_works_without_llm(self) -> None: + """Replay should succeed even with LLM trace events present.""" + from delibera.engine.orchestrator import Engine + from delibera.trace.replay import replay_from_directory + + response_json = { + "recommendation": "Test", + "rationale": ["R1"], + "claims": [{"type": "fact", "text": "F1"}], + "confidence": 0.5, + } + fake_client = FakeLLMClient(response_json=response_json) + + with tempfile.TemporaryDirectory() as tmpdir: + # Run with LLM + engine = Engine( + runs_dir=Path(tmpdir), + llm_client=fake_client, + use_llm_proposer=True, + gates_enabled=False, + ) + run_dir = engine.run("Test question") + + # Replay should work without LLM client + replayed = replay_from_directory(run_dir) + + # Should complete without errors + assert replayed.errors == [] + assert replayed.question == "Test question" + assert "recommendation" in replayed.final_artifact diff --git a/tests/test_evidence.py b/tests/test_evidence.py index 016a7e9..7585571 100644 --- a/tests/test_evidence.py +++ b/tests/test_evidence.py @@ -113,7 +113,8 @@ def test_research_adds_evidence(self) -> None: evidence = event["payload"]["evidence"] assert "evidence_id" in evidence assert "source" in evidence - assert evidence["source"] == "evidence/uv_notes.txt" + # Source is relative to evidence root (uv_notes.txt) + assert "uv_notes.txt" in evidence["source"] assert "excerpt_length" in evidence assert evidence["excerpt_length"] > 0 assert "provenance" in evidence diff --git a/tests/test_refinement.py b/tests/test_refinement.py new file mode 100644 index 0000000..4314f46 --- /dev/null +++ b/tests/test_refinement.py @@ -0,0 +1,536 @@ +"""Tests for the refinement loop functionality.""" + +import tempfile +from pathlib import Path + +import pytest + +from delibera.agents.stub import RefinerStub +from delibera.engine.orchestrator import Engine +from delibera.protocol import ProtocolSpec, load_protocol_from_yaml +from delibera.protocol.spec import ( + ConvergenceSpec, + ExpandSpec, + PruneSpec, + ReduceSpec, + StepSpec, +) + + +class TestConvergenceSpec: + """Tests for ConvergenceSpec validation.""" + + def test_default_values(self) -> None: + """Test ConvergenceSpec has correct defaults.""" + spec = ConvergenceSpec() + assert spec.max_rounds == 0 + assert spec.weak_threshold == 5 + assert spec.score_epsilon == 0.01 + + def test_custom_values(self) -> None: + """Test ConvergenceSpec with custom values.""" + spec = ConvergenceSpec( + max_rounds=5, + weak_threshold=10, + score_epsilon=0.05, + ) + assert spec.max_rounds == 5 + assert spec.weak_threshold == 10 + assert spec.score_epsilon == 0.05 + + def test_negative_max_rounds_raises(self) -> None: + """Test that negative max_rounds raises ValueError.""" + with pytest.raises(ValueError, match="max_rounds must be >= 0"): + ConvergenceSpec(max_rounds=-1) + + def test_negative_weak_threshold_raises(self) -> None: + """Test that negative weak_threshold raises ValueError.""" + with pytest.raises(ValueError, match="weak_threshold must be >= 0"): + ConvergenceSpec(weak_threshold=-1) + + def test_negative_score_epsilon_raises(self) -> None: + """Test that negative score_epsilon raises ValueError.""" + with pytest.raises(ValueError, match="score_epsilon must be >= 0"): + ConvergenceSpec(score_epsilon=-0.01) + + +class TestRefinerStub: + """Tests for RefinerStub agent.""" + + def test_execute_basic(self) -> None: + """Test RefinerStub produces correct output structure.""" + refiner = RefinerStub() + context = { + "node_id": "test_node", + "artifact": {"summary": "Original summary", "score": 0.5}, + "claims": [], + "round": 1, + } + output = refiner.execute(context) + + assert "artifact" in output + assert "refinement_notes" in output + assert "weak_addressed" in output + assert "unsupported_addressed" in output + assert output["role"] == "refiner" + assert output["step"] == "REFINE" + + def test_execute_with_weak_claims(self) -> None: + """Test RefinerStub addresses weak claims.""" + refiner = RefinerStub() + context = { + "node_id": "test_node", + "artifact": {"summary": "Test", "score": 0.5}, + "claims": [ + {"claim_id": "c1", "status": "weak"}, + {"claim_id": "c2", "status": "weak"}, + {"claim_id": "c3", "status": "weak"}, # Third weak claim + ], + "round": 1, + } + output = refiner.execute(context) + + # Should address up to 2 weak claims + assert output["weak_addressed"] == 2 + assert len(output["refinement_notes"]) >= 2 + + def test_execute_with_unsupported_claims(self) -> None: + """Test RefinerStub addresses unsupported claims.""" + refiner = RefinerStub() + context = { + "node_id": "test_node", + "artifact": {"summary": "Test", "score": 0.5}, + "claims": [ + {"claim_id": "c1", "status": "unsupported"}, + {"claim_id": "c2", "status": "unsupported"}, + ], + "round": 1, + } + output = refiner.execute(context) + + # Should address up to 1 unsupported claim per round + assert output["unsupported_addressed"] == 1 + + def test_score_improvement_diminishes(self) -> None: + """Test that score improvement diminishes with each round.""" + refiner = RefinerStub() + original_score = 0.5 + + # Round 1: improvement = 0.05 / 1 = 0.05 + output1 = refiner.execute({"artifact": {"score": original_score}, "claims": [], "round": 1}) + score_after_r1 = output1["artifact"]["score"] + assert score_after_r1 == pytest.approx(0.55, abs=0.01) + + # Round 2: improvement = 0.05 / 2 = 0.025 + output2 = refiner.execute({"artifact": {"score": score_after_r1}, "claims": [], "round": 2}) + score_after_r2 = output2["artifact"]["score"] + assert score_after_r2 == pytest.approx(0.575, abs=0.01) + + def test_artifact_marked_with_round(self) -> None: + """Test that refined artifact includes round metadata.""" + refiner = RefinerStub() + output = refiner.execute( + { + "artifact": {"summary": "Test"}, + "claims": [], + "round": 3, + } + ) + assert output["artifact"]["refinement_round"] == 3 + assert "Refined in round 3" in output["artifact"]["summary"] + + +class TestProtocolWithRefinement: + """Tests for protocol spec with refinement loop.""" + + def _create_refine_protocol(self) -> ProtocolSpec: + """Create a protocol with refinement enabled.""" + return ProtocolSpec( + name="test_refine_protocol", + protocol_version="v1", + max_depth=1, + expand_rules=[ + ExpandSpec( + id="expand_options", + at_step_id="plan", + child_kind="option", + max_children=3, + depth=1, + source="planner_output", + ), + ], + branch_pipeline=[ + StepSpec(id="propose", kind="work", step_name="PROPOSE", role="proposer"), + StepSpec(id="research", kind="work", step_name="RESEARCH", role="researcher"), + StepSpec(id="validate", kind="validate", step_name="CLAIM_CHECK"), + StepSpec(id="redteam", kind="work", step_name="REDTEAM", role="redteam"), + ], + prune=PruneSpec(rule="epistemic_then_score", keep_k=2), + reduce=ReduceSpec(rule="merge_artifacts"), + convergence=ConvergenceSpec( + max_rounds=3, + weak_threshold=5, + score_epsilon=0.01, + ), + refine_loop=[ + StepSpec(id="refine", kind="work", step_name="REFINE", role="refiner"), + StepSpec(id="revalidate", kind="validate", step_name="CLAIM_CHECK"), + ], + ) + + def test_protocol_interpreter_has_refine_loop(self) -> None: + """Test interpreter correctly identifies refine loop.""" + from delibera.protocol.interpreter import ProtocolInterpreter + + protocol = self._create_refine_protocol() + interpreter = ProtocolInterpreter(protocol) + + assert interpreter.has_refine_loop() is True + assert len(interpreter.get_refine_loop_steps()) == 2 + + def test_protocol_interpreter_no_refine_loop(self) -> None: + """Test interpreter correctly identifies no refine loop.""" + from delibera.protocol.interpreter import ProtocolInterpreter + + protocol = self._create_refine_protocol() + protocol.convergence = ConvergenceSpec(max_rounds=0) # Disable refinement + interpreter = ProtocolInterpreter(protocol) + + assert interpreter.has_refine_loop() is False + + def test_protocol_interpreter_empty_refine_loop(self) -> None: + """Test interpreter with empty refine_loop.""" + from delibera.protocol.interpreter import ProtocolInterpreter + + protocol = self._create_refine_protocol() + protocol.refine_loop = [] + protocol.convergence = ConvergenceSpec(max_rounds=3) + interpreter = ProtocolInterpreter(protocol) + + assert interpreter.has_refine_loop() is False + + +class TestYamlProtocolWithRefinement: + """Tests for YAML protocol loading with refinement.""" + + def test_load_protocol_with_convergence(self, tmp_path: Path) -> None: + """Test loading YAML with convergence settings.""" + yaml_content = """ +name: test_refine +protocol_version: v1 +max_depth: 1 +expand_rules: + - id: expand_options + at_step_id: plan + child_kind: option + max_children: 3 + depth: 1 + source: planner_output +branch_pipeline: + - id: propose + kind: work + step_name: PROPOSE + role: proposer +refine_loop: + - id: refine + kind: work + step_name: REFINE + role: refiner +prune: + rule: epistemic_then_score + keep_k: 2 +reduce: + rule: merge_artifacts +convergence: + max_rounds: 5 + weak_threshold: 10 + score_epsilon: 0.02 +""" + yaml_file = tmp_path / "protocol.yaml" + yaml_file.write_text(yaml_content) + + protocol = load_protocol_from_yaml(yaml_file) + + assert protocol.convergence.max_rounds == 5 + assert protocol.convergence.weak_threshold == 10 + assert protocol.convergence.score_epsilon == pytest.approx(0.02) + assert len(protocol.refine_loop) == 1 + + def test_load_protocol_default_convergence(self, tmp_path: Path) -> None: + """Test loading YAML with default convergence settings.""" + yaml_content = """ +name: test_default +protocol_version: v1 +max_depth: 1 +expand_rules: + - id: expand_options + at_step_id: plan + child_kind: option + max_children: 3 + depth: 1 + source: planner_output +branch_pipeline: + - id: propose + kind: work + step_name: PROPOSE + role: proposer +prune: + rule: epistemic_then_score + keep_k: 2 +reduce: + rule: merge_artifacts +""" + yaml_file = tmp_path / "protocol.yaml" + yaml_file.write_text(yaml_content) + + protocol = load_protocol_from_yaml(yaml_file) + + # Should have default values + assert protocol.convergence.max_rounds == 0 + assert protocol.convergence.weak_threshold == 5 + assert protocol.convergence.score_epsilon == 0.01 + + +class TestEngineRefinement: + """Tests for engine refinement loop execution.""" + + def _create_refine_protocol(self, max_rounds: int = 2) -> ProtocolSpec: + """Create a protocol with refinement enabled.""" + return ProtocolSpec( + name="test_refine_protocol", + protocol_version="v1", + max_depth=1, + expand_rules=[ + ExpandSpec( + id="expand_options", + at_step_id="plan", + child_kind="option", + max_children=3, + depth=1, + source="planner_output", + ), + ], + branch_pipeline=[ + StepSpec(id="propose", kind="work", step_name="PROPOSE", role="proposer"), + StepSpec(id="research", kind="work", step_name="RESEARCH", role="researcher"), + StepSpec(id="validate", kind="validate", step_name="CLAIM_CHECK"), + StepSpec(id="redteam", kind="work", step_name="REDTEAM", role="redteam"), + ], + prune=PruneSpec(rule="epistemic_then_score", keep_k=2), + reduce=ReduceSpec(rule="merge_artifacts"), + convergence=ConvergenceSpec( + max_rounds=max_rounds, + weak_threshold=10, # High threshold to allow refinement + score_epsilon=0.001, # Low epsilon to allow refinement + ), + refine_loop=[ + StepSpec(id="refine", kind="work", step_name="REFINE", role="refiner"), + StepSpec(id="revalidate", kind="validate", step_name="CLAIM_CHECK"), + ], + gates_enabled=False, # Disable gates for testing + ) + + def test_engine_no_refinement(self) -> None: + """Test engine without refinement produces no refinement metadata.""" + with tempfile.TemporaryDirectory() as tmpdir: + runs_dir = Path(tmpdir) + engine = Engine( + runs_dir=runs_dir, + gates_enabled=False, + ) + + run_dir = engine.run("Should we adopt uv?") + + # Load artifact + import json + + artifact = json.loads((run_dir / "artifact.json").read_text()) + + # Should not have refinement section (default protocol has max_rounds=0) + assert "refinement" not in artifact + + def test_engine_with_refinement(self) -> None: + """Test engine with refinement produces refinement metadata.""" + with tempfile.TemporaryDirectory() as tmpdir: + runs_dir = Path(tmpdir) + protocol = self._create_refine_protocol(max_rounds=2) + + engine = Engine( + runs_dir=runs_dir, + gates_enabled=False, + protocol=protocol, + ) + + run_dir = engine.run("Should we adopt uv?") + + # Load artifact + import json + + artifact = json.loads((run_dir / "artifact.json").read_text()) + + # Should have refinement section + assert "refinement" in artifact + assert "total_rounds" in artifact["refinement"] + assert "converged" in artifact["refinement"] + assert "stop_reason" in artifact["refinement"] + assert "history" in artifact["refinement"] + + def test_engine_refinement_trace_events(self) -> None: + """Test engine emits refinement trace events.""" + with tempfile.TemporaryDirectory() as tmpdir: + runs_dir = Path(tmpdir) + protocol = self._create_refine_protocol(max_rounds=2) + + engine = Engine( + runs_dir=runs_dir, + gates_enabled=False, + protocol=protocol, + ) + + run_dir = engine.run("Should we adopt uv?") + + # Load trace + import json + + trace_lines = (run_dir / "trace.jsonl").read_text().strip().split("\n") + events = [json.loads(line) for line in trace_lines] + + # Find refinement events + event_types = [e["event_type"] for e in events] + + # Should have at least one round started and completed + assert "refinement_round_started" in event_types + assert "refinement_round_completed" in event_types + + # Count refinement rounds + round_started = sum(1 for t in event_types if t == "refinement_round_started") + assert round_started >= 1 + + def test_engine_refinement_bounded_by_max_rounds(self) -> None: + """Test engine respects max_rounds limit.""" + with tempfile.TemporaryDirectory() as tmpdir: + runs_dir = Path(tmpdir) + protocol = self._create_refine_protocol(max_rounds=3) + + engine = Engine( + runs_dir=runs_dir, + gates_enabled=False, + protocol=protocol, + ) + + run_dir = engine.run("Should we adopt uv?") + + # Load artifact + import json + + artifact = json.loads((run_dir / "artifact.json").read_text()) + + # Should not exceed max_rounds + assert artifact["refinement"]["total_rounds"] <= 3 + + +class TestConvergencePredicates: + """Tests for convergence predicate checking.""" + + def test_convergence_all_predicates_pass(self) -> None: + """Test convergence when all predicates are satisfied.""" + from delibera.epistemics.ledger import Ledger + from delibera.epistemics.models import Claim, ClaimStatus, ClaimType + + # Create a mock node with no issues + class MockNode: + def __init__(self) -> None: + self.ledger = Ledger() + self.ledger.claims = [ + Claim( + claim_id="c1", + text="Test claim", + claim_type=ClaimType.FACT, + confidence=0.9, + owner="test", + status=ClaimStatus.SUPPORTED, + ) + ] + self.artifact = {"score": 0.8} + + mock_node = MockNode() + + # Create engine and check convergence + with tempfile.TemporaryDirectory() as tmpdir: + engine = Engine(runs_dir=Path(tmpdir), gates_enabled=False) + + converged, reason = engine._check_convergence( + merged_node=mock_node, + weak_threshold=5, + score_epsilon=0.02, # Epsilon is 0.02 + previous_score=0.79, # Delta is 0.01, which is < 0.02 + current_score=0.8, + ) + + assert converged is True + assert reason == "all_predicates_satisfied" + + def test_convergence_blocking_objections_remain(self) -> None: + """Test convergence fails with open blocking objections.""" + from delibera.epistemics.ledger import Ledger + from delibera.epistemics.models import ( + Objection, + ObjectionSeverity, + ObjectionStatus, + ) + + class MockNode: + def __init__(self) -> None: + self.ledger = Ledger() + self.ledger.objections = [ + Objection( + objection_id="obj1", + target="artifact", + severity=ObjectionSeverity.BLOCKING, + status=ObjectionStatus.OPEN, + rationale="Test objection", + owner="test", + ) + ] + self.artifact = {"score": 0.8} + + mock_node = MockNode() + + with tempfile.TemporaryDirectory() as tmpdir: + engine = Engine(runs_dir=Path(tmpdir), gates_enabled=False) + + converged, reason = engine._check_convergence( + merged_node=mock_node, + weak_threshold=5, + score_epsilon=0.01, + previous_score=0.8, + current_score=0.8, + ) + + assert converged is False + assert reason == "blocking_objections_remain" + + def test_convergence_score_still_improving(self) -> None: + """Test convergence fails when score is still improving.""" + from delibera.epistemics.ledger import Ledger + + class MockNode: + def __init__(self) -> None: + self.ledger = Ledger() + self.artifact = {"score": 0.8} + + mock_node = MockNode() + + with tempfile.TemporaryDirectory() as tmpdir: + engine = Engine(runs_dir=Path(tmpdir), gates_enabled=False) + + converged, reason = engine._check_convergence( + merged_node=mock_node, + weak_threshold=5, + score_epsilon=0.01, + previous_score=0.5, # Large delta + current_score=0.8, + ) + + assert converged is False + assert reason == "score_still_improving" diff --git a/tests/test_smoke.py b/tests/test_smoke.py new file mode 100644 index 0000000..b54485a --- /dev/null +++ b/tests/test_smoke.py @@ -0,0 +1,134 @@ +"""Smoke tests for Delibera CLI and core functionality. + +These tests verify basic functionality without full deliberation runs. +They are fast and suitable for pre-commit hooks. +""" + +import subprocess +import sys +from pathlib import Path + + +def test_cli_version_command() -> None: + """Test that `delibera version` runs and prints version.""" + result = subprocess.run( + [sys.executable, "-m", "delibera.cli", "version"], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + assert "delibera" in result.stdout.lower() + assert "0." in result.stdout # Version string like 0.1.0 + + +def test_cli_help_command() -> None: + """Test that `delibera --help` runs and shows usage.""" + result = subprocess.run( + [sys.executable, "-m", "delibera.cli", "--help"], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + assert "delibera" in result.stdout.lower() + assert "run" in result.stdout + assert "replay" in result.stdout + + +def test_cli_version_flag() -> None: + """Test that `delibera --version` works.""" + result = subprocess.run( + [sys.executable, "-m", "delibera.cli", "--version"], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + assert "delibera" in result.stdout.lower() + + +def test_imports_work() -> None: + """Test that core imports succeed without errors.""" + from delibera import __version__ + from delibera.engine import Engine + from delibera.protocol import DEFAULT_PROTOCOL, ProtocolSpec + + assert __version__.__version__ + assert Engine + assert DEFAULT_PROTOCOL + assert ProtocolSpec + + +def test_protocol_creation() -> None: + """Test that default protocol can be created and validated.""" + from delibera.protocol import ( + DEFAULT_PROTOCOL, + create_simple_protocol, + create_tree_protocol_v1, + validate_protocol, + ) + + # Default protocol should be valid + errors = validate_protocol(DEFAULT_PROTOCOL) + assert errors == [] + + # Create protocols and validate + simple = create_simple_protocol() + assert simple.name == "simple_protocol" + errors = validate_protocol(simple) + assert errors == [] + + tree = create_tree_protocol_v1() + assert tree.name == "tree_protocol_v1" + errors = validate_protocol(tree) + assert errors == [] + + +def test_yaml_protocol_file_exists() -> None: + """Test that builtin protocol YAML files exist.""" + protocols_dir = Path(__file__).parent.parent / "protocols" + if protocols_dir.exists(): + yaml_files = list(protocols_dir.glob("*.yaml")) + assert len(yaml_files) > 0, "No YAML protocol files found" + + +def test_protocol_warnings() -> None: + """Test that protocol warnings work.""" + from delibera.protocol import ( + ConvergenceSpec, + ExpandSpec, + ProtocolSpec, + PruneSpec, + ReduceSpec, + StepSpec, + warnings_for_protocol, + ) + + # Create a protocol with refine_loop but max_rounds=0 + spec = ProtocolSpec( + name="warning_test", + protocol_version="v1", + max_depth=1, + expand_rules=[ + ExpandSpec( + id="expand", + at_step_id="plan", + child_kind="option", + max_children=3, + depth=1, + source="planner_output", + ), + ], + branch_pipeline=[ + StepSpec(id="propose", kind="work", step_name="PROPOSE", role="proposer"), + ], + refine_loop=[ + StepSpec(id="refine", kind="work", step_name="REFINE", role="refiner"), + ], + prune=PruneSpec(rule="epistemic_then_score", keep_k=2), + reduce=ReduceSpec(rule="merge_artifacts"), + convergence=ConvergenceSpec(max_rounds=0), # Will cause warning + ) + + warnings = warnings_for_protocol(spec) + assert len(warnings) == 1 + assert "refine_loop" in warnings[0] + assert "max_rounds" in warnings[0] diff --git a/tests/test_tools.py b/tests/test_tools.py index 82aa6c3..100874b 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -409,12 +409,12 @@ def test_proposer_uses_calculator(self, tmp_path: Path): tool_events = [e for e in events if e["event_type"].startswith("tool_call")] # Should have tool_call_requested and tool_call_executed for each branch - # (3 branches x 2 tools: calculator in PROPOSE, docs.read in RESEARCH = 6 total) + # 3 branches x 3 tools: calculator in PROPOSE, docs.search + docs.read in RESEARCH = 9 requested = [e for e in tool_events if e["event_type"] == "tool_call_requested"] executed = [e for e in tool_events if e["event_type"] == "tool_call_executed"] - assert len(requested) == 6 # 3 branches x 2 tools - assert len(executed) == 6 # All should succeed + assert len(requested) == 9 # 3 branches x 3 tools + assert len(executed) == 9 # All should succeed # Verify calculator calls from PROPOSE calculator_requests = [e for e in requested if e["payload"]["tool_name"] == "calculator"] @@ -423,10 +423,17 @@ def test_proposer_uses_calculator(self, tmp_path: Path): assert event["payload"]["step"] == "PROPOSE" assert event["payload"]["role"] == "proposer" + # Verify docs.search calls from RESEARCH + docs_search_requests = [e for e in requested if e["payload"]["tool_name"] == "docs.search"] + assert len(docs_search_requests) == 3 + for event in docs_search_requests: + assert event["payload"]["step"] == "RESEARCH" + assert event["payload"]["role"] == "researcher" + # Verify docs.read calls from RESEARCH - docs_requests = [e for e in requested if e["payload"]["tool_name"] == "docs.read"] - assert len(docs_requests) == 3 - for event in docs_requests: + docs_read_requests = [e for e in requested if e["payload"]["tool_name"] == "docs.read"] + assert len(docs_read_requests) == 3 + for event in docs_read_requests: assert event["payload"]["step"] == "RESEARCH" assert event["payload"]["role"] == "researcher" @@ -435,8 +442,12 @@ def test_proposer_uses_calculator(self, tmp_path: Path): for event in calculator_executed: assert "result" in event["payload"]["output"] - docs_executed = [e for e in executed if e["payload"]["tool_name"] == "docs.read"] - for event in docs_executed: + docs_search_executed = [e for e in executed if e["payload"]["tool_name"] == "docs.search"] + for event in docs_search_executed: + assert "results" in event["payload"]["output"] + + docs_read_executed = [e for e in executed if e["payload"]["tool_name"] == "docs.read"] + for event in docs_read_executed: assert "text" in event["payload"]["output"] def test_tool_call_denied_logged(self, tmp_path: Path): @@ -463,18 +474,25 @@ def test_tool_call_denied_logged(self, tmp_path: Path): for line in f: events.append(json.loads(line)) - # Check for denied events (3 calculator + 3 docs.read = 6) + # Check for denied events (3 calculator + 3 docs.search + 3 docs.read = 9) denied_events = [e for e in events if e["event_type"] == "tool_call_denied"] - assert len(denied_events) == 6 # 3 branches x 2 tools + assert len(denied_events) == 9 # 3 branches x 3 tools calculator_denied = [e for e in denied_events if e["payload"]["tool_name"] == "calculator"] assert len(calculator_denied) == 3 for event in calculator_denied: assert "not in enabled_tools" in event["payload"]["reason"] - docs_denied = [e for e in denied_events if e["payload"]["tool_name"] == "docs.read"] - assert len(docs_denied) == 3 - for event in docs_denied: + docs_search_denied = [ + e for e in denied_events if e["payload"]["tool_name"] == "docs.search" + ] + assert len(docs_search_denied) == 3 + for event in docs_search_denied: + assert "not in enabled_tools" in event["payload"]["reason"] + + docs_read_denied = [e for e in denied_events if e["payload"]["tool_name"] == "docs.read"] + assert len(docs_read_denied) == 3 + for event in docs_read_denied: assert "not in enabled_tools" in event["payload"]["reason"] def test_claim_check_step_allows_calculator(self, tmp_path: Path): diff --git a/tests/tools_docs/__init__.py b/tests/tools_docs/__init__.py new file mode 100644 index 0000000..2a0c1ba --- /dev/null +++ b/tests/tools_docs/__init__.py @@ -0,0 +1 @@ +"""Tests for docs tools (docs.read, docs.search).""" diff --git a/tests/tools_docs/test_docs_search.py b/tests/tools_docs/test_docs_search.py new file mode 100644 index 0000000..362afdb --- /dev/null +++ b/tests/tools_docs/test_docs_search.py @@ -0,0 +1,377 @@ +"""Tests for the docs.search tool and evidence pack functionality.""" + +from pathlib import Path + +import pytest + +from delibera.tools.builtin.docs import DocsReadTool, DocsSearchTool +from delibera.tools.policy import ( + GlobalPolicy, + PolicyContext, + PolicyEngine, + StepPolicyOverride, +) +from delibera.tools.registry import ToolRegistry, create_default_registry +from delibera.tools.router import ToolRouter +from delibera.tools.spec import ToolCapability, ToolDenied + + +class TestDocsSearchTool: + """Tests for DocsSearchTool.""" + + def test_capability_is_discovery(self) -> None: + """Test that docs.search is classified as a discovery tool.""" + tool = DocsSearchTool() + assert tool.capability == ToolCapability.DISCOVERY + assert tool.is_discovery is True + + def test_docs_read_capability_is_inspect(self) -> None: + """Test that docs.read is classified as an inspect tool.""" + tool = DocsReadTool() + assert tool.capability == ToolCapability.INSPECT + assert tool.is_discovery is False + + def test_search_empty_directory(self, tmp_path: Path) -> None: + """Test searching an empty evidence directory.""" + tool = DocsSearchTool(evidence_root=tmp_path) + result = tool.execute({"query": "test", "max_results": 5}) + + assert result["results"] == [] + + def test_search_nonexistent_directory(self, tmp_path: Path) -> None: + """Test searching a non-existent evidence directory.""" + nonexistent = tmp_path / "does_not_exist" + tool = DocsSearchTool(evidence_root=nonexistent) + result = tool.execute({"query": "test", "max_results": 5}) + + assert result["results"] == [] + assert "warning" in result + + def test_search_returns_deterministic_results(self, tmp_path: Path) -> None: + """Test that search results are deterministic.""" + # Create evidence files + (tmp_path / "alpha.txt").write_text("This file contains fast performance data.") + (tmp_path / "beta.txt").write_text("Another file with fast execution metrics.") + (tmp_path / "gamma.txt").write_text("No matching content here.") + + tool = DocsSearchTool(evidence_root=tmp_path) + + # Run search multiple times + result1 = tool.execute({"query": "fast", "max_results": 5}) + result2 = tool.execute({"query": "fast", "max_results": 5}) + result3 = tool.execute({"query": "fast", "max_results": 5}) + + # Results should be identical across runs + assert result1 == result2 == result3 + + # Should find two files with "fast" + assert len(result1["results"]) == 2 + + # Results should be sorted by score desc, then path asc + paths = [r["path"] for r in result1["results"]] + assert paths == ["alpha.txt", "beta.txt"] # Same score, sorted by path + + def test_search_score_ordering(self, tmp_path: Path) -> None: + """Test that results are ordered by score (token count).""" + # Create files with different match counts + (tmp_path / "one_match.txt").write_text("uv is a tool.") + (tmp_path / "three_matches.txt").write_text("uv uv uv is the best tool.") + (tmp_path / "two_matches.txt").write_text("uv is great uv") + + tool = DocsSearchTool(evidence_root=tmp_path) + result = tool.execute({"query": "uv", "max_results": 5}) + + # Should be ordered by score (match count) descending + paths = [r["path"] for r in result["results"]] + assert paths[0] == "three_matches.txt" # 3 matches + assert paths[1] == "two_matches.txt" # 2 matches + assert paths[2] == "one_match.txt" # 1 match + + def test_search_snippet_extraction(self, tmp_path: Path) -> None: + """Test that snippets are extracted around matches.""" + content = "This is some prefix text. UV is extremely fast. This is suffix text." + (tmp_path / "test.txt").write_text(content) + + tool = DocsSearchTool(evidence_root=tmp_path) + result = tool.execute({"query": "fast", "max_results": 5}) + + assert len(result["results"]) == 1 + snippet = result["results"][0]["snippet"] + assert "fast" in snippet.lower() + # Snippet should be non-empty and reasonable length + assert 10 < len(snippet) <= 250 + + def test_search_max_results_limit(self, tmp_path: Path) -> None: + """Test that max_results is respected.""" + # Create many files + for i in range(10): + (tmp_path / f"file{i}.txt").write_text(f"This is test file {i} with search term.") + + tool = DocsSearchTool(evidence_root=tmp_path) + result = tool.execute({"query": "test", "max_results": 3}) + + assert len(result["results"]) == 3 + + def test_search_validates_query(self) -> None: + """Test that invalid queries are rejected.""" + tool = DocsSearchTool() + + with pytest.raises(ValueError, match="query"): + tool.validate_input({}) + + with pytest.raises(ValueError, match="query"): + tool.validate_input({"query": ""}) + + with pytest.raises(ValueError, match="max_results"): + tool.validate_input({"query": "test", "max_results": 0}) + + def test_search_text_files_only(self, tmp_path: Path) -> None: + """Test that only text files are searched.""" + (tmp_path / "text.txt").write_text("search term here") + (tmp_path / "data.json").write_text('{"key": "search term"}') + (tmp_path / "binary.bin").write_bytes(b"\x00\x01search term") + + tool = DocsSearchTool(evidence_root=tmp_path) + result = tool.execute({"query": "search", "max_results": 10}) + + # Should find txt and json (text-like), but not bin + paths = {r["path"] for r in result["results"]} + assert "text.txt" in paths + assert "data.json" in paths + assert "binary.bin" not in paths + + +class TestDocsSearchPolicy: + """Tests for docs.search policy enforcement.""" + + def test_docs_search_denied_in_claim_check(self, tmp_path: Path) -> None: + """Test that docs.search is denied during CLAIM_CHECK step.""" + # Create evidence pack + (tmp_path / "test.txt").write_text("test content") + + # Create registry with docs.search + registry = create_default_registry(evidence_root=tmp_path) + + # Create policy engine with default step overrides + policy = PolicyEngine( + global_policy=GlobalPolicy( + enabled_tools={"docs.search", "docs.read", "calculator"}, + ), + step_override=StepPolicyOverride( + evidence_local_steps={"CLAIM_CHECK"}, + ), + ) + + # Create router + router = ToolRouter(registry=registry, policy_engine=policy) + + # docs.search should be denied during CLAIM_CHECK + with pytest.raises(ToolDenied) as exc_info: + router.call( + role="validator", + step="CLAIM_CHECK", + tool_name="docs.search", + tool_input={"query": "test"}, + ) + + assert "discovery" in exc_info.value.reason.lower() + + def test_docs_search_allowed_in_research(self, tmp_path: Path) -> None: + """Test that docs.search is allowed during RESEARCH step.""" + # Create evidence pack + (tmp_path / "test.txt").write_text("searchable content") + + registry = create_default_registry(evidence_root=tmp_path) + policy = PolicyEngine( + global_policy=GlobalPolicy( + enabled_tools={"docs.search", "docs.read", "calculator"}, + ), + ) + router = ToolRouter(registry=registry, policy_engine=policy) + + # Should succeed during RESEARCH + result = router.call( + role="researcher", + step="RESEARCH", + tool_name="docs.search", + tool_input={"query": "searchable"}, + ) + + assert "results" in result + assert len(result["results"]) == 1 + + def test_docs_read_denied_for_uncited_source_in_claim_check(self, tmp_path: Path) -> None: + """Test that docs.read is denied for non-cited sources during CLAIM_CHECK.""" + # Create evidence file + (tmp_path / "uncited.txt").write_text("uncited content") + + registry = create_default_registry(evidence_root=tmp_path) + policy = PolicyEngine( + global_policy=GlobalPolicy( + enabled_tools={"docs.read", "docs.search"}, + ), + step_override=StepPolicyOverride( + evidence_local_steps={"CLAIM_CHECK"}, + evidence_local_tools={"docs.read"}, + ), + ) + router = ToolRouter(registry=registry, policy_engine=policy) + + # Context with no cited sources + context = PolicyContext(allowed_evidence_sources=set()) + + with pytest.raises(ToolDenied) as exc_info: + router.call( + role="validator", + step="CLAIM_CHECK", + tool_name="docs.read", + tool_input={"path": "uncited.txt"}, + policy_context=context, + ) + + assert "not in allowed evidence" in exc_info.value.reason + + def test_docs_read_allowed_for_cited_source_in_claim_check(self, tmp_path: Path) -> None: + """Test that docs.read is allowed for cited sources during CLAIM_CHECK.""" + # Create evidence file + (tmp_path / "cited.txt").write_text("cited content for validation") + + registry = create_default_registry(evidence_root=tmp_path) + policy = PolicyEngine( + global_policy=GlobalPolicy( + enabled_tools={"docs.read", "docs.search"}, + ), + step_override=StepPolicyOverride( + evidence_local_steps={"CLAIM_CHECK"}, + evidence_local_tools={"docs.read"}, + ), + ) + router = ToolRouter(registry=registry, policy_engine=policy) + + # Context with cited source + context = PolicyContext(allowed_evidence_sources={"cited.txt"}) + + # Should succeed for cited source + result = router.call( + role="validator", + step="CLAIM_CHECK", + tool_name="docs.read", + tool_input={"path": "cited.txt"}, + policy_context=context, + ) + + assert "text" in result + assert "cited content" in result["text"] + + +class TestResearchWithDocsSearch: + """Tests for ResearcherStub using docs.search + docs.read.""" + + def test_research_uses_docs_search_then_read(self, tmp_path: Path) -> None: + """Test that research calls docs.search then docs.read.""" + from delibera.agents.stub import ResearcherStub + + # Create evidence pack + (tmp_path / "uv_notes.txt").write_text( + "UV is 10-100x faster than pip for dependency resolution." + ) + + registry = create_default_registry(evidence_root=tmp_path) + + # Track tool calls + tool_calls: list[tuple[str, dict]] = [] + + def tool_callback(name: str, input: dict) -> dict: + tool_calls.append((name, input)) + tool = registry.get(name) + return tool.execute(input) + + researcher = ResearcherStub() + result = researcher.execute( + context={ + "label": "Option A: Direct approach", + "question": "Should we adopt uv?", + }, + tool=tool_callback, + ) + + # Should have called docs.search first + search_calls = [(n, i) for n, i in tool_calls if n == "docs.search"] + assert len(search_calls) >= 1 + + # Should have called docs.read after search + read_calls = [(n, i) for n, i in tool_calls if n == "docs.read"] + assert len(read_calls) >= 1 + + # Should have found evidence + assert len(result["evidence"]) > 0 + assert "fast" in result["evidence"][0]["excerpt"].lower() + + def test_research_fallback_when_search_unavailable(self, tmp_path: Path) -> None: + """Test that research falls back to direct read if search fails.""" + from delibera.agents.stub import ResearcherStub + + # Create evidence file + (tmp_path / "uv_notes.txt").write_text("UV is 10-100x faster than pip.") + + # Create registry without docs.search + registry = ToolRegistry() + from delibera.tools.builtin.docs import DocsReadTool + + registry.register(DocsReadTool(evidence_root=tmp_path)) + + def tool_callback(name: str, input: dict) -> dict: + tool = registry.get(name) + return tool.execute(input) + + researcher = ResearcherStub() + result = researcher.execute( + context={ + "label": "Option A: Direct approach", + "question": "Should we adopt uv?", + }, + tool=tool_callback, + ) + + # Should still find evidence via fallback + assert "notes" in result + # Notes should indicate fallback was used + notes_text = " ".join(result["notes"]) + assert "fallback" in notes_text.lower() or "failed" in notes_text.lower() + + +class TestToolCapabilityClassification: + """Tests for tool capability classification.""" + + def test_calculator_is_compute(self) -> None: + """Test that calculator is classified as compute.""" + from delibera.tools.builtin.calculator import CalculatorTool + + tool = CalculatorTool() + assert tool.capability == ToolCapability.COMPUTE + assert tool.is_discovery is False + + def test_docs_read_is_inspect(self) -> None: + """Test that docs.read is classified as inspect.""" + tool = DocsReadTool() + assert tool.capability == ToolCapability.INSPECT + assert tool.is_discovery is False + + def test_docs_search_is_discovery(self) -> None: + """Test that docs.search is classified as discovery.""" + tool = DocsSearchTool() + assert tool.capability == ToolCapability.DISCOVERY + assert tool.is_discovery is True + + def test_default_registry_has_all_tools(self, tmp_path: Path) -> None: + """Test that default registry includes docs.search.""" + registry = create_default_registry(evidence_root=tmp_path) + + assert registry.has("calculator") + assert registry.has("docs.read") + assert registry.has("docs.search") + + # Verify capabilities + assert registry.get("calculator").capability == ToolCapability.COMPUTE + assert registry.get("docs.read").capability == ToolCapability.INSPECT + assert registry.get("docs.search").capability == ToolCapability.DISCOVERY diff --git a/uv.lock b/uv.lock index c465c62..b1561e4 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,29 @@ version = 1 revision = 3 requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version < '3.13'", +] + +[[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 = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] [[package]] name = "cfgv" @@ -11,6 +34,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + [[package]] name = "click" version = "8.3.1" @@ -41,6 +121,11 @@ dependencies = [ { name = "pyyaml" }, ] +[package.optional-dependencies] +llm = [ + { name = "google-generativeai" }, +] + [package.dev-dependencies] dev = [ { name = "mypy" }, @@ -53,8 +138,10 @@ dev = [ [package.metadata] requires-dist = [ { name = "click", specifier = ">=8.1.0" }, + { name = "google-generativeai", marker = "extra == 'llm'", specifier = ">=0.8.0" }, { name = "pyyaml", specifier = ">=6.0.0" }, ] +provides-extras = ["llm"] [package.metadata.requires-dev] dev = [ @@ -83,6 +170,214 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/30/ab407e2ec752aa541704ed8f93c11e2a5d92c168b8a755d818b74a3c5c2d/filelock-3.20.2-py3-none-any.whl", hash = "sha256:fbba7237d6ea277175a32c54bb71ef814a8546d8601269e1bfc388de333974e8", size = 16697, upload-time = "2026-01-02T15:33:31.133Z" }, ] +[[package]] +name = "google-ai-generativelanguage" +version = "0.6.15" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", version = "2.25.2", source = { registry = "https://pypi.org/simple" }, extra = ["grpc"], marker = "python_full_version >= '3.14'" }, + { name = "google-api-core", version = "2.29.0", source = { registry = "https://pypi.org/simple" }, extra = ["grpc"], marker = "python_full_version < '3.14'" }, + { name = "google-auth" }, + { name = "proto-plus" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/11/d1/48fe5d7a43d278e9f6b5ada810b0a3530bbeac7ed7fcbcd366f932f05316/google_ai_generativelanguage-0.6.15.tar.gz", hash = "sha256:8f6d9dc4c12b065fe2d0289026171acea5183ebf2d0b11cefe12f3821e159ec3", size = 1375443, upload-time = "2025-01-13T21:50:47.459Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/a3/67b8a6ff5001a1d8864922f2d6488dc2a14367ceb651bc3f09a947f2f306/google_ai_generativelanguage-0.6.15-py3-none-any.whl", hash = "sha256:5a03ef86377aa184ffef3662ca28f19eeee158733e45d7947982eb953c6ebb6c", size = 1327356, upload-time = "2025-01-13T21:50:44.174Z" }, +] + +[[package]] +name = "google-api-core" +version = "2.25.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", +] +dependencies = [ + { name = "google-auth", marker = "python_full_version >= '3.14'" }, + { name = "googleapis-common-protos", marker = "python_full_version >= '3.14'" }, + { name = "proto-plus", marker = "python_full_version >= '3.14'" }, + { name = "protobuf", marker = "python_full_version >= '3.14'" }, + { name = "requests", marker = "python_full_version >= '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/cd/63f1557235c2440fe0577acdbc32577c5c002684c58c7f4d770a92366a24/google_api_core-2.25.2.tar.gz", hash = "sha256:1c63aa6af0d0d5e37966f157a77f9396d820fba59f9e43e9415bc3dc5baff300", size = 166266, upload-time = "2025-10-03T00:07:34.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/d8/894716a5423933f5c8d2d5f04b16f052a515f78e815dab0c2c6f1fd105dc/google_api_core-2.25.2-py3-none-any.whl", hash = "sha256:e9a8f62d363dc8424a8497f4c2a47d6bcda6c16514c935629c257ab5d10210e7", size = 162489, upload-time = "2025-10-03T00:07:32.924Z" }, +] + +[package.optional-dependencies] +grpc = [ + { name = "grpcio", marker = "python_full_version >= '3.14'" }, + { name = "grpcio-status", marker = "python_full_version >= '3.14'" }, +] + +[[package]] +name = "google-api-core" +version = "2.29.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.13.*'", + "python_full_version < '3.13'", +] +dependencies = [ + { name = "google-auth", marker = "python_full_version < '3.14'" }, + { name = "googleapis-common-protos", marker = "python_full_version < '3.14'" }, + { name = "proto-plus", marker = "python_full_version < '3.14'" }, + { name = "protobuf", marker = "python_full_version < '3.14'" }, + { name = "requests", marker = "python_full_version < '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/10/05572d33273292bac49c2d1785925f7bc3ff2fe50e3044cf1062c1dde32e/google_api_core-2.29.0.tar.gz", hash = "sha256:84181be0f8e6b04006df75ddfe728f24489f0af57c96a529ff7cf45bc28797f7", size = 177828, upload-time = "2026-01-08T22:21:39.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/b6/85c4d21067220b9a78cfb81f516f9725ea6befc1544ec9bd2c1acd97c324/google_api_core-2.29.0-py3-none-any.whl", hash = "sha256:d30bc60980daa36e314b5d5a3e5958b0200cb44ca8fa1be2b614e932b75a3ea9", size = 173906, upload-time = "2026-01-08T22:21:36.093Z" }, +] + +[package.optional-dependencies] +grpc = [ + { name = "grpcio", marker = "python_full_version < '3.14'" }, + { name = "grpcio-status", marker = "python_full_version < '3.14'" }, +] + +[[package]] +name = "google-api-python-client" +version = "2.188.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", version = "2.25.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, + { name = "google-api-core", version = "2.29.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "google-auth" }, + { name = "google-auth-httplib2" }, + { name = "httplib2" }, + { name = "uritemplate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/d7/14613c7efbab5b428b400961f5dbac46ad9e019c44e1f3fd14d67c33111c/google_api_python_client-2.188.0.tar.gz", hash = "sha256:5c469db6614f071009e3e5bb8b6aeeccae3beb3647fa9c6cd97f0d551edde0b6", size = 14302906, upload-time = "2026-01-13T22:15:13.747Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/67/a99a7d79d7a37a67cb8008f1d7dcedc46d29c6df5063aeb446112afd4aa4/google_api_python_client-2.188.0-py3-none-any.whl", hash = "sha256:3cad1b68f9d48b82b93d77927e8370a6f43f33d97848242601f14a93a1c70ef5", size = 14870005, upload-time = "2026-01-13T22:15:11.345Z" }, +] + +[[package]] +name = "google-auth" +version = "2.47.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1-modules" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/3c/ec64b9a275ca22fa1cd3b6e77fefcf837b0732c890aa32d2bd21313d9b33/google_auth-2.47.0.tar.gz", hash = "sha256:833229070a9dfee1a353ae9877dcd2dec069a8281a4e72e72f77d4a70ff945da", size = 323719, upload-time = "2026-01-06T21:55:31.045Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/18/79e9008530b79527e0d5f79e7eef08d3b179b7f851cfd3a2f27822fbdfa9/google_auth-2.47.0-py3-none-any.whl", hash = "sha256:c516d68336bfde7cf0da26aab674a36fedcf04b37ac4edd59c597178760c3498", size = 234867, upload-time = "2026-01-06T21:55:28.6Z" }, +] + +[[package]] +name = "google-auth-httplib2" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "httplib2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/ad/c1f2b1175096a8d04cf202ad5ea6065f108d26be6fc7215876bde4a7981d/google_auth_httplib2-0.3.0.tar.gz", hash = "sha256:177898a0175252480d5ed916aeea183c2df87c1f9c26705d74ae6b951c268b0b", size = 11134, upload-time = "2025-12-15T22:13:51.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/d5/3c97526c8796d3caf5f4b3bed2b05e8a7102326f00a334e7a438237f3b22/google_auth_httplib2-0.3.0-py3-none-any.whl", hash = "sha256:426167e5df066e3f5a0fc7ea18768c08e7296046594ce4c8c409c2457dd1f776", size = 9529, upload-time = "2025-12-15T22:13:51.048Z" }, +] + +[[package]] +name = "google-generativeai" +version = "0.8.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-ai-generativelanguage" }, + { name = "google-api-core", version = "2.25.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, + { name = "google-api-core", version = "2.29.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "google-api-python-client" }, + { name = "google-auth" }, + { name = "protobuf" }, + { name = "pydantic" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/0f/ef33b5bb71437966590c6297104c81051feae95d54b11ece08533ef937d3/google_generativeai-0.8.6-py3-none-any.whl", hash = "sha256:37a0eaaa95e5bbf888828e20a4a1b2c196cc9527d194706e58a68ff388aeb0fa", size = 155098, upload-time = "2025-12-16T17:53:58.61Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.72.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433, upload-time = "2025-11-06T18:29:24.087Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" }, +] + +[[package]] +name = "grpcio" +version = "1.76.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b6/e0/318c1ce3ae5a17894d5791e87aea147587c9e702f24122cc7a5c8bbaeeb1/grpcio-1.76.0.tar.gz", hash = "sha256:7be78388d6da1a25c0d5ec506523db58b18be22d9c37d8d3a32c08be4987bd73", size = 12785182, upload-time = "2025-10-21T16:23:12.106Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/05/8e29121994b8d959ffa0afd28996d452f291b48cfc0875619de0bde2c50c/grpcio-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:81fd9652b37b36f16138611c7e884eb82e0cec137c40d3ef7c3f9b3ed00f6ed8", size = 5799718, upload-time = "2025-10-21T16:21:17.939Z" }, + { url = "https://files.pythonhosted.org/packages/d9/75/11d0e66b3cdf998c996489581bdad8900db79ebd83513e45c19548f1cba4/grpcio-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:04bbe1bfe3a68bbfd4e52402ab7d4eb59d72d02647ae2042204326cf4bbad280", size = 11825627, upload-time = "2025-10-21T16:21:20.466Z" }, + { url = "https://files.pythonhosted.org/packages/28/50/2f0aa0498bc188048f5d9504dcc5c2c24f2eb1a9337cd0fa09a61a2e75f0/grpcio-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d388087771c837cdb6515539f43b9d4bf0b0f23593a24054ac16f7a960be16f4", size = 6359167, upload-time = "2025-10-21T16:21:23.122Z" }, + { url = "https://files.pythonhosted.org/packages/66/e5/bbf0bb97d29ede1d59d6588af40018cfc345b17ce979b7b45424628dc8bb/grpcio-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9f8f757bebaaea112c00dba718fc0d3260052ce714e25804a03f93f5d1c6cc11", size = 7044267, upload-time = "2025-10-21T16:21:25.995Z" }, + { url = "https://files.pythonhosted.org/packages/f5/86/f6ec2164f743d9609691115ae8ece098c76b894ebe4f7c94a655c6b03e98/grpcio-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:980a846182ce88c4f2f7e2c22c56aefd515daeb36149d1c897f83cf57999e0b6", size = 6573963, upload-time = "2025-10-21T16:21:28.631Z" }, + { url = "https://files.pythonhosted.org/packages/60/bc/8d9d0d8505feccfdf38a766d262c71e73639c165b311c9457208b56d92ae/grpcio-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f92f88e6c033db65a5ae3d97905c8fea9c725b63e28d5a75cb73b49bda5024d8", size = 7164484, upload-time = "2025-10-21T16:21:30.837Z" }, + { url = "https://files.pythonhosted.org/packages/67/e6/5d6c2fc10b95edf6df9b8f19cf10a34263b7fd48493936fffd5085521292/grpcio-1.76.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4baf3cbe2f0be3289eb68ac8ae771156971848bb8aaff60bad42005539431980", size = 8127777, upload-time = "2025-10-21T16:21:33.577Z" }, + { url = "https://files.pythonhosted.org/packages/3f/c8/dce8ff21c86abe025efe304d9e31fdb0deaaa3b502b6a78141080f206da0/grpcio-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:615ba64c208aaceb5ec83bfdce7728b80bfeb8be97562944836a7a0a9647d882", size = 7594014, upload-time = "2025-10-21T16:21:41.882Z" }, + { url = "https://files.pythonhosted.org/packages/e0/42/ad28191ebf983a5d0ecef90bab66baa5a6b18f2bfdef9d0a63b1973d9f75/grpcio-1.76.0-cp312-cp312-win32.whl", hash = "sha256:45d59a649a82df5718fd9527ce775fd66d1af35e6d31abdcdc906a49c6822958", size = 3984750, upload-time = "2025-10-21T16:21:44.006Z" }, + { url = "https://files.pythonhosted.org/packages/9e/00/7bd478cbb851c04a48baccaa49b75abaa8e4122f7d86da797500cccdd771/grpcio-1.76.0-cp312-cp312-win_amd64.whl", hash = "sha256:c088e7a90b6017307f423efbb9d1ba97a22aa2170876223f9709e9d1de0b5347", size = 4704003, upload-time = "2025-10-21T16:21:46.244Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ed/71467ab770effc9e8cef5f2e7388beb2be26ed642d567697bb103a790c72/grpcio-1.76.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:26ef06c73eb53267c2b319f43e6634c7556ea37672029241a056629af27c10e2", size = 5807716, upload-time = "2025-10-21T16:21:48.475Z" }, + { url = "https://files.pythonhosted.org/packages/2c/85/c6ed56f9817fab03fa8a111ca91469941fb514e3e3ce6d793cb8f1e1347b/grpcio-1.76.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:45e0111e73f43f735d70786557dc38141185072d7ff8dc1829d6a77ac1471468", size = 11821522, upload-time = "2025-10-21T16:21:51.142Z" }, + { url = "https://files.pythonhosted.org/packages/ac/31/2b8a235ab40c39cbc141ef647f8a6eb7b0028f023015a4842933bc0d6831/grpcio-1.76.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83d57312a58dcfe2a3a0f9d1389b299438909a02db60e2f2ea2ae2d8034909d3", size = 6362558, upload-time = "2025-10-21T16:21:54.213Z" }, + { url = "https://files.pythonhosted.org/packages/bd/64/9784eab483358e08847498ee56faf8ff6ea8e0a4592568d9f68edc97e9e9/grpcio-1.76.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3e2a27c89eb9ac3d81ec8835e12414d73536c6e620355d65102503064a4ed6eb", size = 7049990, upload-time = "2025-10-21T16:21:56.476Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/8c12319a6369434e7a184b987e8e9f3b49a114c489b8315f029e24de4837/grpcio-1.76.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61f69297cba3950a524f61c7c8ee12e55c486cb5f7db47ff9dcee33da6f0d3ae", size = 6575387, upload-time = "2025-10-21T16:21:59.051Z" }, + { url = "https://files.pythonhosted.org/packages/15/0f/f12c32b03f731f4a6242f771f63039df182c8b8e2cf8075b245b409259d4/grpcio-1.76.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a15c17af8839b6801d554263c546c69c4d7718ad4321e3166175b37eaacca77", size = 7166668, upload-time = "2025-10-21T16:22:02.049Z" }, + { url = "https://files.pythonhosted.org/packages/ff/2d/3ec9ce0c2b1d92dd59d1c3264aaec9f0f7c817d6e8ac683b97198a36ed5a/grpcio-1.76.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:25a18e9810fbc7e7f03ec2516addc116a957f8cbb8cbc95ccc80faa072743d03", size = 8124928, upload-time = "2025-10-21T16:22:04.984Z" }, + { url = "https://files.pythonhosted.org/packages/1a/74/fd3317be5672f4856bcdd1a9e7b5e17554692d3db9a3b273879dc02d657d/grpcio-1.76.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:931091142fd8cc14edccc0845a79248bc155425eee9a98b2db2ea4f00a235a42", size = 7589983, upload-time = "2025-10-21T16:22:07.881Z" }, + { url = "https://files.pythonhosted.org/packages/45/bb/ca038cf420f405971f19821c8c15bcbc875505f6ffadafe9ffd77871dc4c/grpcio-1.76.0-cp313-cp313-win32.whl", hash = "sha256:5e8571632780e08526f118f74170ad8d50fb0a48c23a746bef2a6ebade3abd6f", size = 3984727, upload-time = "2025-10-21T16:22:10.032Z" }, + { url = "https://files.pythonhosted.org/packages/41/80/84087dc56437ced7cdd4b13d7875e7439a52a261e3ab4e06488ba6173b0a/grpcio-1.76.0-cp313-cp313-win_amd64.whl", hash = "sha256:f9f7bd5faab55f47231ad8dba7787866b69f5e93bc306e3915606779bbfb4ba8", size = 4702799, upload-time = "2025-10-21T16:22:12.709Z" }, + { url = "https://files.pythonhosted.org/packages/b4/46/39adac80de49d678e6e073b70204091e76631e03e94928b9ea4ecf0f6e0e/grpcio-1.76.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:ff8a59ea85a1f2191a0ffcc61298c571bc566332f82e5f5be1b83c9d8e668a62", size = 5808417, upload-time = "2025-10-21T16:22:15.02Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f5/a4531f7fb8b4e2a60b94e39d5d924469b7a6988176b3422487be61fe2998/grpcio-1.76.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:06c3d6b076e7b593905d04fdba6a0525711b3466f43b3400266f04ff735de0cd", size = 11828219, upload-time = "2025-10-21T16:22:17.954Z" }, + { url = "https://files.pythonhosted.org/packages/4b/1c/de55d868ed7a8bd6acc6b1d6ddc4aa36d07a9f31d33c912c804adb1b971b/grpcio-1.76.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd5ef5932f6475c436c4a55e4336ebbe47bd3272be04964a03d316bbf4afbcbc", size = 6367826, upload-time = "2025-10-21T16:22:20.721Z" }, + { url = "https://files.pythonhosted.org/packages/59/64/99e44c02b5adb0ad13ab3adc89cb33cb54bfa90c74770f2607eea629b86f/grpcio-1.76.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b331680e46239e090f5b3cead313cc772f6caa7d0fc8de349337563125361a4a", size = 7049550, upload-time = "2025-10-21T16:22:23.637Z" }, + { url = "https://files.pythonhosted.org/packages/43/28/40a5be3f9a86949b83e7d6a2ad6011d993cbe9b6bd27bea881f61c7788b6/grpcio-1.76.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2229ae655ec4e8999599469559e97630185fdd53ae1e8997d147b7c9b2b72cba", size = 6575564, upload-time = "2025-10-21T16:22:26.016Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a9/1be18e6055b64467440208a8559afac243c66a8b904213af6f392dc2212f/grpcio-1.76.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:490fa6d203992c47c7b9e4a9d39003a0c2bcc1c9aa3c058730884bbbb0ee9f09", size = 7176236, upload-time = "2025-10-21T16:22:28.362Z" }, + { url = "https://files.pythonhosted.org/packages/0f/55/dba05d3fcc151ce6e81327541d2cc8394f442f6b350fead67401661bf041/grpcio-1.76.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:479496325ce554792dba6548fae3df31a72cef7bad71ca2e12b0e58f9b336bfc", size = 8125795, upload-time = "2025-10-21T16:22:31.075Z" }, + { url = "https://files.pythonhosted.org/packages/4a/45/122df922d05655f63930cf42c9e3f72ba20aadb26c100ee105cad4ce4257/grpcio-1.76.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c9b93f79f48b03ada57ea24725d83a30284a012ec27eab2cf7e50a550cbbbcc", size = 7592214, upload-time = "2025-10-21T16:22:33.831Z" }, + { url = "https://files.pythonhosted.org/packages/4a/6e/0b899b7f6b66e5af39e377055fb4a6675c9ee28431df5708139df2e93233/grpcio-1.76.0-cp314-cp314-win32.whl", hash = "sha256:747fa73efa9b8b1488a95d0ba1039c8e2dca0f741612d80415b1e1c560febf4e", size = 4062961, upload-time = "2025-10-21T16:22:36.468Z" }, + { url = "https://files.pythonhosted.org/packages/19/41/0b430b01a2eb38ee887f88c1f07644a1df8e289353b78e82b37ef988fb64/grpcio-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:922fa70ba549fce362d2e2871ab542082d66e2aaf0c19480ea453905b01f384e", size = 4834462, upload-time = "2025-10-21T16:22:39.772Z" }, +] + +[[package]] +name = "grpcio-status" +version = "1.71.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/d1/b6e9877fedae3add1afdeae1f89d1927d296da9cf977eca0eb08fb8a460e/grpcio_status-1.71.2.tar.gz", hash = "sha256:c7a97e176df71cdc2c179cd1847d7fc86cca5832ad12e9798d7fed6b7a1aab50", size = 13677, upload-time = "2025-06-28T04:24:05.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/58/317b0134129b556a93a3b0afe00ee675b5657f0155509e22fcb853bafe2d/grpcio_status-1.71.2-py3-none-any.whl", hash = "sha256:803c98cb6a8b7dc6dbb785b1111aed739f241ab5e9da0bba96888aa74704cfd3", size = 14424, upload-time = "2025-06-28T04:23:42.136Z" }, +] + +[[package]] +name = "httplib2" +version = "0.31.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/df/6eb1d485a513776bbdbb1c919b72e59b5acc51c5e7ef28ad1cd444e252a3/httplib2-0.31.1.tar.gz", hash = "sha256:21591655ac54953624c6ab8d587c71675e379e31e2cfe3147c83c11e9ef41f92", size = 250746, upload-time = "2026-01-13T12:14:14.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/d8/1b05076441c2f01e4b64f59e5255edc2f0384a711b6d618845c023dc269b/httplib2-0.31.1-py3-none-any.whl", hash = "sha256:d520d22fa7e50c746a7ed856bac298c4300105d01bc2d8c2580a9b57fb9ed617", size = 91101, upload-time = "2026-01-13T12:14:12.676Z" }, +] + [[package]] name = "identify" version = "2.6.15" @@ -92,6 +387,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, ] +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -256,6 +560,139 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, ] +[[package]] +name = "proto-plus" +version = "1.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/89/9cbe2f4bba860e149108b683bc2efec21f14d5f7ed6e25562ad86acbc373/proto_plus-1.27.0.tar.gz", hash = "sha256:873af56dd0d7e91836aee871e5799e1c6f1bda86ac9a983e0bb9f0c266a568c4", size = 56158, upload-time = "2025-12-16T13:46:25.729Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/24/3b7a0818484df9c28172857af32c2397b6d8fcd99d9468bd4684f98ebf0a/proto_plus-1.27.0-py3-none-any.whl", hash = "sha256:1baa7f81cf0f8acb8bc1f6d085008ba4171eaf669629d1b6d1673b21ed1c0a82", size = 50205, upload-time = "2025-12-16T13:46:24.76Z" }, +] + +[[package]] +name = "protobuf" +version = "5.29.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/29/d09e70352e4e88c9c7a198d5645d7277811448d76c23b00345670f7c8a38/protobuf-5.29.5.tar.gz", hash = "sha256:bc1463bafd4b0929216c35f437a8e28731a2b7fe3d98bb77a600efced5a15c84", size = 425226, upload-time = "2025-05-28T23:51:59.82Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/11/6e40e9fc5bba02988a214c07cf324595789ca7820160bfd1f8be96e48539/protobuf-5.29.5-cp310-abi3-win32.whl", hash = "sha256:3f1c6468a2cfd102ff4703976138844f78ebd1fb45f49011afc5139e9e283079", size = 422963, upload-time = "2025-05-28T23:51:41.204Z" }, + { url = "https://files.pythonhosted.org/packages/81/7f/73cefb093e1a2a7c3ffd839e6f9fcafb7a427d300c7f8aef9c64405d8ac6/protobuf-5.29.5-cp310-abi3-win_amd64.whl", hash = "sha256:3f76e3a3675b4a4d867b52e4a5f5b78a2ef9565549d4037e06cf7b0942b1d3fc", size = 434818, upload-time = "2025-05-28T23:51:44.297Z" }, + { url = "https://files.pythonhosted.org/packages/dd/73/10e1661c21f139f2c6ad9b23040ff36fee624310dc28fba20d33fdae124c/protobuf-5.29.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e38c5add5a311f2a6eb0340716ef9b039c1dfa428b28f25a7838ac329204a671", size = 418091, upload-time = "2025-05-28T23:51:45.907Z" }, + { url = "https://files.pythonhosted.org/packages/6c/04/98f6f8cf5b07ab1294c13f34b4e69b3722bb609c5b701d6c169828f9f8aa/protobuf-5.29.5-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:fa18533a299d7ab6c55a238bf8629311439995f2e7eca5caaff08663606e9015", size = 319824, upload-time = "2025-05-28T23:51:47.545Z" }, + { url = "https://files.pythonhosted.org/packages/85/e4/07c80521879c2d15f321465ac24c70efe2381378c00bf5e56a0f4fbac8cd/protobuf-5.29.5-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:63848923da3325e1bf7e9003d680ce6e14b07e55d0473253a690c3a8b8fd6e61", size = 319942, upload-time = "2025-05-28T23:51:49.11Z" }, + { url = "https://files.pythonhosted.org/packages/7e/cc/7e77861000a0691aeea8f4566e5d3aa716f2b1dece4a24439437e41d3d25/protobuf-5.29.5-py3-none-any.whl", hash = "sha256:6cf42630262c59b2d8de33954443d94b746c952b01434fc58a417fdbd2e84bd5", size = 172823, upload-time = "2025-05-28T23:51:58.157Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +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/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -265,6 +702,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pyparsing" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/c1/1d9de9aeaa1b89b0186e5fe23294ff6517fce1bc69149185577cd31016b2/pyparsing-3.3.1.tar.gz", hash = "sha256:47fad0f17ac1e2cad3de3b458570fbc9b03560aa029ed5e16ee5554da9a2251c", size = 1550512, upload-time = "2025-12-23T03:14:04.391Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/40/2614036cdd416452f5bf98ec037f38a1afb17f327cb8e6b652d4729e0af8/pyparsing-3.3.1-py3-none-any.whl", hash = "sha256:023b5e7e5520ad96642e2c6db4cb683d3970bd640cdf7115049a6e9c3682df82", size = 121793, upload-time = "2025-12-23T03:14:02.103Z" }, +] + [[package]] name = "pytest" version = "9.0.2" @@ -327,6 +773,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + [[package]] name = "ruff" version = "0.14.10" @@ -353,6 +826,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" }, ] +[[package]] +name = "tqdm" +version = "4.67.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, +] + [[package]] name = "types-pyyaml" version = "6.0.12.20250915" @@ -371,6 +856,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[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 = "uritemplate" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267, upload-time = "2025-06-02T15:12:06.318Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488, upload-time = "2025-06-02T15:12:03.405Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + [[package]] name = "virtualenv" version = "20.35.4"