"""Admin CRUD for reusable parser profiles + generate/preview.""" from __future__ import annotations from typing import Any from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from sqlalchemy.orm import Session from contracts.heuristic_profile import HeuristicProfile from contracts.llm_profile import LlmProfile from ..database import get_db from ..deps import verify_admin from ..models import ParseJob, ParserProfile from ..schemas import ParserProfileCreate, ParserProfileRead, ParserProfileUpdate from ..services import parser_builder as builder router = APIRouter( prefix="/admin/parser-profiles", tags=["parser-profiles"], dependencies=[Depends(verify_admin)], ) class GenerateRequest(BaseModel): sample_post: str = Field(min_length=1) hint: str | None = None current_profile: dict[str, Any] | None = None class GenerateResponse(BaseModel): profile: dict[str, Any] preview: dict[str, str] empty_fields: list[str] = Field(default_factory=list) class PreviewRequest(BaseModel): sample_post: str = Field(min_length=1) heuristic_profile: dict[str, Any] | None = None # Accept alias used by older builder UI profile: dict[str, Any] | None = None class PreviewResponse(BaseModel): fields: dict[str, str] matched: bool = True missing_required: list[str] = Field(default_factory=list) class PreviewLlmRequest(BaseModel): sample_post: str = Field(min_length=1) llm_profile: dict[str, Any] class PreviewLlmResponse(BaseModel): fields: dict[str, str] events: list[dict[str, str]] = Field(default_factory=list) matched: bool = True missing_required: list[str] = Field(default_factory=list) is_event: bool = True matched_count: int = 0 def _validate_heuristic_profile(raw: dict[str, Any] | None) -> dict[str, Any] | None: if raw is None: return None try: return HeuristicProfile.model_validate(raw).model_dump() except Exception as exc: raise HTTPException(status_code=400, detail=f"Invalid heuristic_profile: {exc}") from exc def _validate_llm_profile(raw: dict[str, Any] | None) -> dict[str, Any] | None: if raw is None: return None try: return LlmProfile.model_validate(raw).model_dump() except Exception as exc: raise HTTPException(status_code=400, detail=f"Invalid llm_profile: {exc}") from exc def _normalize_kind(kind: str | None) -> str: value = (kind or "heuristic").strip().lower() if value not in ("heuristic", "llm"): raise HTTPException(status_code=400, detail="kind must be heuristic or llm") return value def _profile_status( *, kind: str, heuristic_profile: dict | None, llm_profile: dict | None, explicit: str | None = None, ) -> str: if explicit: return explicit if kind == "llm": return "ready" if llm_profile else "draft" return "ready" if heuristic_profile else "draft" @router.get("/target-fields") def target_fields(): return {"fields": builder.get_target_fields()} @router.get("/llm-defaults") def llm_defaults(): from contracts.llm_profile import DEFAULT_EXTRACT_SCHEMA, DEFAULT_INSTRUCTION return { "instruction": DEFAULT_INSTRUCTION, "extract_schema": DEFAULT_EXTRACT_SCHEMA, "required_fields": [], "multi_event": False, } @router.post("/generate", response_model=GenerateResponse) async def generate_profile(payload: GenerateRequest): if not builder.deepseek_enabled(): raise HTTPException( status_code=503, detail="DEEPSEEK_API_KEY is not set on ca-api. Add it to .env for parser generation.", ) try: profile = await builder.generate_profile( payload.sample_post, hint=payload.hint, current_profile=payload.current_profile, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except Exception as exc: raise HTTPException( status_code=502, detail=f"DeepSeek generate failed: {exc}", ) from exc dumped = profile.model_dump() if payload.current_profile and isinstance(payload.current_profile.get("required_fields"), list): dumped["required_fields"] = payload.current_profile["required_fields"] dumped = HeuristicProfile.model_validate(dumped).model_dump() preview = builder.preview_with_profile(payload.sample_post, dumped) return GenerateResponse( profile=dumped, preview=preview, empty_fields=builder.empty_preview_fields(preview), ) @router.post("/preview", response_model=PreviewResponse) def preview_profile(payload: PreviewRequest): raw = payload.heuristic_profile if payload.heuristic_profile is not None else payload.profile if raw is None: raise HTTPException(status_code=400, detail="heuristic_profile is required") try: HeuristicProfile.model_validate(raw) fields, matched, missing = builder.match_preview(payload.sample_post, raw) except Exception as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return PreviewResponse(fields=fields, matched=matched, missing_required=missing) @router.post("/preview-llm", response_model=PreviewLlmResponse) async def preview_llm_profile(payload: PreviewLlmRequest): if not builder.deepseek_enabled(): raise HTTPException( status_code=503, detail="DEEPSEEK_API_KEY is not set on ca-api. Add it to .env for LLM preview.", ) try: LlmProfile.model_validate(payload.llm_profile) events, fields, matched, missing, is_event, matched_count = await builder.preview_llm_extract( payload.sample_post, payload.llm_profile, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except Exception as exc: raise HTTPException( status_code=502, detail=f"DeepSeek LLM preview failed: {exc}", ) from exc return PreviewLlmResponse( fields=fields, events=events, matched=matched, missing_required=missing, is_event=is_event, matched_count=matched_count, ) @router.get("", response_model=list[ParserProfileRead]) def list_profiles(db: Session = Depends(get_db)): return db.query(ParserProfile).order_by(ParserProfile.id.desc()).all() @router.post("", response_model=ParserProfileRead, status_code=201) def create_profile(payload: ParserProfileCreate, db: Session = Depends(get_db)): kind = _normalize_kind(payload.kind) heuristic = _validate_heuristic_profile(payload.heuristic_profile) llm = _validate_llm_profile(payload.llm_profile) if kind == "heuristic" and llm and not heuristic: # ignore stray llm blob when creating heuristic llm = None if kind == "llm" and heuristic and not llm: heuristic = None profile = ParserProfile( name=payload.name.strip(), kind=kind, sample_post=payload.sample_post or "", heuristic_profile=heuristic if kind == "heuristic" else None, llm_profile=llm if kind == "llm" else None, status=_profile_status( kind=kind, heuristic_profile=heuristic if kind == "heuristic" else None, llm_profile=llm if kind == "llm" else None, explicit=payload.status, ), ) db.add(profile) db.commit() db.refresh(profile) return profile @router.get("/{profile_id}", response_model=ParserProfileRead) def get_profile(profile_id: int, db: Session = Depends(get_db)): profile = db.query(ParserProfile).filter(ParserProfile.id == profile_id).first() if not profile: raise HTTPException(status_code=404, detail="Profile not found") return profile @router.patch("/{profile_id}", response_model=ParserProfileRead) def update_profile( profile_id: int, payload: ParserProfileUpdate, db: Session = Depends(get_db), ): profile = db.query(ParserProfile).filter(ParserProfile.id == profile_id).first() if not profile: raise HTTPException(status_code=404, detail="Profile not found") if payload.name is not None: profile.name = payload.name.strip() if payload.sample_post is not None: profile.sample_post = payload.sample_post if payload.kind is not None: profile.kind = _normalize_kind(payload.kind) kind = _normalize_kind(profile.kind) if "heuristic_profile" in payload.model_fields_set: profile.heuristic_profile = _validate_heuristic_profile(payload.heuristic_profile) if "llm_profile" in payload.model_fields_set: profile.llm_profile = _validate_llm_profile(payload.llm_profile) # Keep only the blob matching kind if kind == "heuristic": profile.llm_profile = None else: profile.heuristic_profile = None if payload.status is not None: profile.status = payload.status elif ( "heuristic_profile" in payload.model_fields_set or "llm_profile" in payload.model_fields_set or payload.kind is not None ): profile.status = _profile_status( kind=kind, heuristic_profile=profile.heuristic_profile, llm_profile=profile.llm_profile, ) db.commit() db.refresh(profile) return profile @router.delete("/{profile_id}", status_code=204) def delete_profile(profile_id: int, db: Session = Depends(get_db)): profile = db.query(ParserProfile).filter(ParserProfile.id == profile_id).first() if not profile: raise HTTPException(status_code=404, detail="Profile not found") linked = ( db.query(ParseJob) .filter(ParseJob.profile_id == profile_id, ParseJob.status.in_(("queued", "running"))) .count() ) if linked: raise HTTPException( status_code=409, detail="Cannot delete profile used by queued/running jobs", ) db.delete(profile) db.commit()