"""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 ..database import get_db 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"]) 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] 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 _profile_status(heuristic_profile: dict | None, explicit: str | None = None) -> str: if explicit: return explicit return "ready" if heuristic_profile else "draft" @router.get("/target-fields") def target_fields(): return {"fields": builder.get_target_fields()} @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() 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 = builder.preview_with_profile(payload.sample_post, raw) except Exception as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return PreviewResponse(fields=fields) @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)): heuristic = _validate_heuristic_profile(payload.heuristic_profile) profile = ParserProfile( name=payload.name.strip(), sample_post=payload.sample_post or "", heuristic_profile=heuristic, status=_profile_status(heuristic, 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 "heuristic_profile" in payload.model_fields_set: profile.heuristic_profile = _validate_heuristic_profile(payload.heuristic_profile) if payload.status is not None: profile.status = payload.status elif "heuristic_profile" in payload.model_fields_set: profile.status = _profile_status(profile.heuristic_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()