| """Deterministic routing gate β policy check over the router's intent. |
| |
| After the LLM router picks an intent, the gate checks it against the per-analysis |
| Analysis State and returns the **effective** intent: allow as-is, or redirect. No |
| LLM, no I/O in `gate()` itself. |
| |
| Only one rule has teeth in v1: an analytical request (`structured_flow`) requires a |
| validated problem statement (`problem_validated is True`); otherwise it is |
| redirected to `problem_statement` so the user defines the goal first. Everything |
| else passes through. `generate_report` is not a router intent (button / report |
| API), so it is not gated here. |
| |
| `AnalysisState` is the locked 8-field contract (mirrors the `analysis_states` DB |
| table). `get_analysis_state` reads the real per-analysis row via `AnalysisStateStore` |
| (#9, landed); it fails closed to a not-validated stub on a missing row or read error. |
| See `ORCHESTRATOR_REWORK_PLAN.md` Β§4. |
| """ |
|
|
| from __future__ import annotations |
|
|
| from datetime import UTC, datetime |
|
|
| from pydantic import BaseModel |
|
|
| from src.agents.orchestration import Intent |
| from src.middlewares.logging import get_logger |
|
|
| logger = get_logger("gate") |
|
|
|
|
| class AnalysisState(BaseModel): |
| """Per-analysis state the gate + Help skill read every turn (locked contract). |
| |
| `problem_validated` is the gate driver; `report_id` is null until a report |
| exists. Field names mirror the `analysis_states` table so the DB read swaps in |
| without touching readers. |
| """ |
|
|
| id: str |
| analysis_title: str |
| problem_statement: str |
| problem_validated: bool = False |
| user_id: str |
| report_id: str | None = None |
| created_at: datetime |
| updated_at: datetime |
|
|
|
|
| def gate(intent: Intent, state: AnalysisState) -> Intent: |
| """Return the effective intent (NEUTERED 2026-06-24 β passes everything through). |
| |
| The `problem_validated` gate was removed: analysis is no longer gated on a validated |
| problem statement (the goal is now two user-entered fields, `objective` + |
| `business_questions`, captured at onboarding with no agent validation). Kept as a |
| no-op seam so gating can be restored without re-threading call sites. |
| """ |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| return intent |
|
|
|
|
| def stub_analysis_state(*, problem_validated: bool = False) -> AnalysisState: |
| """Hardcoded Analysis State for building/testing before the DB lands (#9). |
| |
| Shared fixture so the gate, the Help skill, and tests all exercise the same |
| shape. `problem_validated=True` simulates a passed interview. |
| """ |
| now = datetime.now(UTC) |
| return AnalysisState( |
| id="stub-analysis", |
| analysis_title="Stub analysis", |
| problem_statement="Stub problem statement" if problem_validated else "", |
| problem_validated=problem_validated, |
| user_id="stub-user", |
| report_id=None, |
| created_at=now, |
| updated_at=now, |
| ) |
|
|
|
|
| async def get_analysis_state(analysis_id: str) -> AnalysisState: |
| """Load the Analysis State for an analysis (shared id with the chat room). |
| |
| Reads the `analysis_states` row via `AnalysisStateStore`. Never-throw seam: a |
| missing row (e.g. a legacy room created before this table) or a read failure |
| degrades to a **not-validated** stub, so the gate fails closed (β steer to |
| `problem_statement`) rather than running ungated analysis. The store import is |
| lazy so this module stays import-safe without a DB. |
| """ |
| try: |
| from src.agents.state_store import AnalysisStateStore |
|
|
| state = await AnalysisStateStore().get(analysis_id) |
| except Exception as exc: |
| logger.warning( |
| "get_analysis_state read failed β default not-validated", |
| analysis_id=analysis_id, |
| error=str(exc), |
| ) |
| return stub_analysis_state(problem_validated=False) |
| if state is None: |
| logger.debug("analysis_state missing β default not-validated", analysis_id=analysis_id) |
| return stub_analysis_state(problem_validated=False) |
| return state |
|
|