"""Validate Agent — validates user inputs before any AI call is made. Uses Haiku for semantic coherence checks only.""" import asyncio, json, os, re import anthropic from json_repair import repair_json from pydantic import BaseModel from typing import Optional from core.sanitizer import sanitize_objective from core.date_engine import validate_date_order from core.planner import ERROR_MESSAGES class CoherenceResult(BaseModel): coherent: bool = True suggestion: Optional[str] = None # PROMPT v1.0 SYSTEM_PROMPT = """ You are validating a project brief for completeness. Return ONLY valid JSON: { "coherent": true/false, "suggestion": "string or null" } coherent = true if the objective is specific enough to generate a plan. suggestion = a short improvement tip if coherent is false, else null. """ def _check_required_fields( objective: str, teams: list, start_date: str, end_date: str, start_ai: bool, end_ai: bool, ) -> list[str]: """Pure Python field-level checks. Returns list of error strings (empty = OK).""" errors: list[str] = [] if not objective or not objective.strip(): errors.append(ERROR_MESSAGES["no_objective"]) if not teams or len(teams) < 1: errors.append(ERROR_MESSAGES["no_teams"]) start_provided = bool(start_date and start_date.strip()) end_provided = bool(end_date and end_date.strip()) if not start_provided and not start_ai and not end_provided and not end_ai: errors.append(ERROR_MESSAGES["no_dates"]) if start_provided and end_provided and not start_ai and not end_ai: if not validate_date_order(start_date, end_date): errors.append(ERROR_MESSAGES["date_order"]) return errors async def _check_coherence(objective: str) -> CoherenceResult: """Call Haiku to check if objective is semantically coherent.""" try: client = anthropic.AsyncAnthropic(api_key=os.environ.get("ANTHROPIC_API_KEY")) response = await client.messages.create( model="claude-haiku-4-5-20251001", max_tokens=256, system=SYSTEM_PROMPT, messages=[{"role": "user", "content": sanitize_objective(objective)}], ) raw_text = response.content[0].text.strip() if raw_text.startswith("```"): first_newline = raw_text.find("\n") raw_text = raw_text[first_newline + 1:] if first_newline != -1 else raw_text[3:] if raw_text.rstrip().endswith("```"): raw_text = raw_text.rstrip()[:-3].rstrip() try: data = json.loads(raw_text) except json.JSONDecodeError: data = repair_json(raw_text, return_objects=True) if not isinstance(data, dict): data = {} return CoherenceResult(**data) except anthropic.APITimeoutError: return CoherenceResult(coherent=True) except anthropic.RateLimitError: return CoherenceResult(coherent=True) except json.JSONDecodeError: return CoherenceResult(coherent=True) except Exception: return CoherenceResult(coherent=True) async def run( objective: str, teams: list, start_date: str = "", end_date: str = "", start_ai: bool = False, end_ai: bool = False, ) -> dict: """ Main entry point. Returns: {"valid": bool, "errors": list[str], "coherent": bool, "suggestion": str | None} Step 1: run pure Python field checks Step 2: if field checks pass, run coherence check Step 3: assemble result dict """ errors = _check_required_fields(objective, teams, start_date, end_date, start_ai, end_ai) coherence = CoherenceResult(coherent=True) if not errors: coherence = await _check_coherence(objective) # Coherence is advisory only — a low coherence score adds a suggestion but never blocks generation. # Hard field errors (missing objective/teams/dates) are the only blockers. return { "valid": len(errors) == 0, "errors": errors, "coherent": coherence.coherent, "suggestion": coherence.suggestion, }