""" Pydantic models for the RFQ Qualifier agent — skill CRUD (mirrors ChatAgentSkill* in models.py one-for-one, but kept as its own independent set per the "separate skill system per agent" decision) plus the qualification-run request/response and the structured decision schema the agent's final tool call must satisfy. """ from pydantic import BaseModel, Field from typing import Optional, List, Literal # --------------------------------------------------------------------------- # Skill CRUD — same anatomy as ChatAgentSkill* in models.py (name/description/instructions, # references/assets/templates/scripts/evals), independent request models per the "separate copy # per agent" decision. # --------------------------------------------------------------------------- class QualifierSkillReferenceItem(BaseModel): title: str = Field("", max_length=300) content: str = Field("", max_length=20000) class QualifierSkillAssetItem(BaseModel): name: str = Field("", max_length=300) content: str = Field("", max_length=20000) class QualifierSkillTemplateItem(BaseModel): name: str = Field("", max_length=300) content: str = Field("", max_length=20000) class QualifierSkillScriptItem(BaseModel): # The live background agent never executes this content. An admin can run it explicitly via # the sandboxed /scripts/{index}/run route (subprocess-isolated, secrets stripped, timed out) — # see qualifier_script_runner.py. name: str = Field("", max_length=300) description: str = Field("", max_length=2000) language: str = Field("", max_length=40) content: str = Field("", max_length=20000) last_run_status: Optional[str] = None last_run_output: Optional[str] = None last_run_at: Optional[str] = None class QualifierSkillEvalItem(BaseModel): """Unlike the chat agent's evals (a prompt + text assertion), a qualifier eval runs a real stored sample RFQ (input_ref points at a QualifierRun or an uploaded fixture) through the full pipeline and checks the resulting decision — closer to a regression test than a prompt probe.""" name: str = Field("", max_length=300) input_ref: str = Field( "", max_length=500, description="Reference to the fixture input, e.g. 'fixture:120-bloor-st-e.eml'" ) expected_decision: Optional[Literal["qualify", "disqualify", "needs_review"]] = None expected_project_type: Optional[Literal["modernization", "new_construction", "unknown"]] = None assertion_type: Literal["decision_equals", "must_contain", "must_not_contain", "llm_judge"] = "decision_equals" assertion_value: str = Field("", max_length=2000) last_run_status: Optional[str] = None last_run_detail: Optional[str] = None last_run_at: Optional[str] = None class QualifierSkillCreateRequest(BaseModel): name: str = Field(..., min_length=1, max_length=200) description: str = Field("", max_length=1000) instructions: str = Field(..., min_length=1, max_length=14000) references: List[QualifierSkillReferenceItem] = Field(default_factory=list, max_length=20) assets: List[QualifierSkillAssetItem] = Field(default_factory=list, max_length=20) templates: List[QualifierSkillTemplateItem] = Field(default_factory=list, max_length=20) scripts: List[QualifierSkillScriptItem] = Field(default_factory=list, max_length=20) evals: List[QualifierSkillEvalItem] = Field(default_factory=list, max_length=20) class QualifierSkillPatchRequest(BaseModel): name: Optional[str] = Field(None, min_length=1, max_length=200) description: Optional[str] = Field(None, max_length=1000) instructions: Optional[str] = Field(None, min_length=1, max_length=14000) references: Optional[List[QualifierSkillReferenceItem]] = Field(None, max_length=20) assets: Optional[List[QualifierSkillAssetItem]] = Field(None, max_length=20) templates: Optional[List[QualifierSkillTemplateItem]] = Field(None, max_length=20) scripts: Optional[List[QualifierSkillScriptItem]] = Field(None, max_length=20) evals: Optional[List[QualifierSkillEvalItem]] = Field(None, max_length=20) is_active: Optional[bool] = None sort_order: Optional[int] = None class QualifierSkillImportRequest(BaseModel): content: str = Field(..., min_length=1, max_length=100000) # --------------------------------------------------------------------------- # Qualification run — the structured decision object is what the agent's final forced tool call # ("submit_qualification_decision") must produce; OpenAI-style tool-calling validates the call # arguments against this shape at generation time, so this schema doubles as the tool's JSON Schema # (see qualifier_agent.py's DECISION_TOOL_SCHEMA, built from this model) and as the API response # model / QualifierRun.result_json shape. # --------------------------------------------------------------------------- class QualifierMatchedItem(BaseModel): item: str = Field(..., max_length=500, description="The RFQ line item as described in the spec") category: str = Field( ..., max_length=100, description="door_operator | clutch | panel_adaptor | car_door_panel | roller_guide | governor | car_safety | door_protective_device | other_in_scope", ) match: Literal["exact", "ambiguous"] = "exact" catalog_ref: Optional[str] = Field(None, max_length=200, description="Matched pricelist catalog code, if exact") note: str = Field("", max_length=1000) class QualifierExcludedItem(BaseModel): item: str = Field(..., max_length=500) reason: str = Field(..., max_length=300, description="Short out-of-scope category, e.g. 'motor', 'controller', 'cab_interior', 'wiring'") class QualifierDecision(BaseModel): qualify: Literal["qualify", "disqualify", "needs_review"] project_type: Literal["modernization", "new_construction", "unknown"] matched_items: List[QualifierMatchedItem] = Field(default_factory=list) excluded_items: List[QualifierExcludedItem] = Field(default_factory=list) flags: List[str] = Field(default_factory=list, description="e.g. 'needs_engineering_review', 'missing_deadline', 'competitor_already_awarded'") deadline: Optional[str] = Field(None, max_length=100, description="Stated bid/quote deadline, as found in the RFQ, or null if not stated") project_name: str = Field("", max_length=300) reasoning: str = Field(..., max_length=4000, description="Why this decision, referencing the qualify policy") confidence: float = Field(..., ge=0.0, le=1.0) class QualifierRunResponse(BaseModel): id: int status: Literal["running", "complete", "error"] decision: Optional[str] = None project_type: Optional[str] = None confidence: Optional[float] = None result: Optional[QualifierDecision] = None error_detail: Optional[str] = None project_name: str = "" sender_email: str = "" input_filename: str = "" total_tokens: int = 0 started_at: Optional[str] = None finished_at: Optional[str] = None