diff --git a/hooks/useAiPeerReview.ts b/hooks/useAiPeerReview.ts deleted file mode 100644 index e7dc04667..000000000 --- a/hooks/useAiPeerReview.ts +++ /dev/null @@ -1,268 +0,0 @@ -'use client'; - -import { useCallback, useEffect, useState } from 'react'; -import { - AiPeerReviewService, - type CreateProposalReviewPayload, - type RfpBriefRefreshPayload, -} from '@/services/aiPeerReview.service'; -import type { - ExecutiveSummaryResponse, - GrantComparisonResponse, - ProposalReview, - RfpSummary, - RfpSummaryMissing, -} from '@/types/aiPeerReview'; -import { isRfpSummaryMissing } from '@/types/aiPeerReview'; - -// ── useProposalReview ───────────────────────────────────────────────────────── - -interface UseProposalReviewState { - review: ProposalReview | null; - isLoading: boolean; - error: string | null; -} - -type RefetchProposalReviewFn = () => Promise; -type UseProposalReviewReturn = [UseProposalReviewState, RefetchProposalReviewFn]; - -/** - * Fetches a single AI proposal review by id (no polling). - */ -export function useProposalReview(reviewId: number | string | null): UseProposalReviewReturn { - const [review, setReview] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - const fetch = useCallback(async () => { - if (reviewId == null) return; - try { - setIsLoading(true); - setError(null); - const detail = await AiPeerReviewService.getProposalReview(reviewId); - setReview(detail); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'Failed to fetch proposal review'; - setError(message); - setReview(null); - } finally { - setIsLoading(false); - } - }, [reviewId]); - - useEffect(() => { - if (reviewId != null) { - void fetch(); - } else { - setReview(null); - setError(null); - } - }, [reviewId, fetch]); - - return [{ review, isLoading, error }, fetch]; -} - -// ── useCreateProposalReview ─────────────────────────────────────────────────── - -interface UseCreateProposalReviewState { - review: ProposalReview | null; - isLoading: boolean; - error: string | null; -} - -type CreateProposalReviewFn = (payload: CreateProposalReviewPayload) => Promise; -type UseCreateProposalReviewReturn = [UseCreateProposalReviewState, CreateProposalReviewFn]; - -export function useCreateProposalReview(): UseCreateProposalReviewReturn { - const [review, setReview] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - const createReview = useCallback(async (payload: CreateProposalReviewPayload) => { - setIsLoading(true); - setError(null); - try { - const response = await AiPeerReviewService.createProposalReview(payload); - setReview(response); - return response; - } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'Failed to create proposal review'; - setError(message); - throw err; - } finally { - setIsLoading(false); - } - }, []); - - return [{ review, isLoading, error }, createReview]; -} - -// ── useGrantComparison ──────────────────────────────────────────────────────── - -interface UseGrantComparisonState { - data: GrantComparisonResponse | null; - isLoading: boolean; - error: string | null; -} - -type RefetchGrantComparisonFn = () => Promise; -type UseGrantComparisonReturn = [UseGrantComparisonState, RefetchGrantComparisonFn]; - -export function useGrantComparison(grantId: number | string | null): UseGrantComparisonReturn { - const [data, setData] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - const fetch = useCallback(async () => { - if (grantId == null) return; - try { - setIsLoading(true); - setError(null); - const response = await AiPeerReviewService.getGrantComparison(grantId); - setData(response); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'Failed to fetch grant comparison'; - setError(message); - setData(null); - } finally { - setIsLoading(false); - } - }, [grantId]); - - useEffect(() => { - if (grantId != null) { - void fetch(); - } else { - setData(null); - setError(null); - } - }, [grantId, fetch]); - - return [{ data, isLoading, error }, fetch]; -} - -// ── useRfpSummary ───────────────────────────────────────────────────────────── - -interface UseRfpSummaryState { - summary: RfpSummary | RfpSummaryMissing | null; - isLoading: boolean; - error: string | null; - /** True when API returned the 404 "no row yet" envelope (see `isRfpSummaryMissing`). */ - isMissing: boolean; -} - -type RefetchRfpSummaryFn = () => Promise; -type UseRfpSummaryReturn = [UseRfpSummaryState, RefetchRfpSummaryFn]; - -export function useRfpSummary(grantId: number | string | null): UseRfpSummaryReturn { - const [summary, setSummary] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - const fetch = useCallback(async () => { - if (grantId == null) return; - try { - setIsLoading(true); - setError(null); - const response = await AiPeerReviewService.getRfpSummary(grantId); - setSummary(response); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'Failed to fetch RFP summary'; - setError(message); - setSummary(null); - } finally { - setIsLoading(false); - } - }, [grantId]); - - useEffect(() => { - if (grantId != null) { - void fetch(); - } else { - setSummary(null); - setError(null); - } - }, [grantId, fetch]); - - const isMissing = summary != null && isRfpSummaryMissing(summary); - - return [{ summary, isLoading, error, isMissing }, fetch]; -} - -// ── useRefreshRfpSummary ────────────────────────────────────────────────────── - -interface UseRefreshRfpSummaryState { - summary: RfpSummary | null; - isLoading: boolean; - error: string | null; -} - -type RefreshRfpSummaryFn = ( - grantId: number | string, - payload?: RfpBriefRefreshPayload -) => Promise; - -type UseRefreshRfpSummaryReturn = [UseRefreshRfpSummaryState, RefreshRfpSummaryFn]; - -export function useRefreshRfpSummary(): UseRefreshRfpSummaryReturn { - const [summary, setSummary] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - const refresh = useCallback(async (gid: number | string, payload?: RfpBriefRefreshPayload) => { - setIsLoading(true); - setError(null); - try { - const response = await AiPeerReviewService.refreshRfpSummary(gid, payload); - setSummary(response); - return response; - } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'Failed to refresh RFP summary'; - setError(message); - throw err; - } finally { - setIsLoading(false); - } - }, []); - - return [{ summary, isLoading, error }, refresh]; -} - -// ── useGenerateExecutiveSummary ─────────────────────────────────────────────── - -interface UseGenerateExecutiveSummaryState { - result: ExecutiveSummaryResponse | null; - isLoading: boolean; - error: string | null; -} - -type GenerateExecutiveSummaryFn = (grantId: number | string) => Promise; -type UseGenerateExecutiveSummaryReturn = [ - UseGenerateExecutiveSummaryState, - GenerateExecutiveSummaryFn, -]; - -export function useGenerateExecutiveSummary(): UseGenerateExecutiveSummaryReturn { - const [result, setResult] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - const generate = useCallback(async (grantId: number | string) => { - setIsLoading(true); - setError(null); - try { - const response = await AiPeerReviewService.generateExecutiveSummary(grantId); - setResult(response); - return response; - } catch (err: unknown) { - const message = - err instanceof Error ? err.message : 'Failed to generate executive comparison summary'; - setError(message); - throw err; - } finally { - setIsLoading(false); - } - }, []); - - return [{ result, isLoading, error }, generate]; -} diff --git a/services/aiPeerReview.service.ts b/services/aiPeerReview.service.ts deleted file mode 100644 index bdd60ae12..000000000 --- a/services/aiPeerReview.service.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { ApiClient } from './client'; -import { ApiError } from './types'; -import { - transformExecutiveSummary, - transformGrantComparisonResponse, - transformProposalReview, - transformRfpSummary, - type ExecutiveSummaryResponse, - type GrantComparisonResponse, - type ProposalReview, - type RfpSummary, - type RfpSummaryMissing, -} from '@/types/aiPeerReview'; - -// ── API enums / unions ───────────────────────────── - -export type ReviewStatus = 'pending' | 'processing' | 'completed' | 'failed'; - -export type OverallRating = 'excellent' | 'good' | 'poor'; - -export type OverallConfidence = 'High' | 'Medium' | 'Low'; - -export type CategoryScoreLabel = 'High' | 'Medium' | 'Low' | 'N/A'; - -export type ItemDecisionValue = 'Yes' | 'No' | 'Partial' | 'N/A'; - -export type RfpStatus = 'pending' | 'processing' | 'completed' | 'failed'; - -export type EditorialCategoryScore = 'high' | 'medium' | 'low'; - -export type CategoryKey = - | 'overall_impact' - | 'importance_significance_innovation' - | 'rigor_and_feasibility' - | 'additional_review_criteria' - | 'funding_opportunity_fit' - | 'methods_rigor' - | 'statistical_analysis_plan' - | 'feasibility_and_execution' - | 'scientific_impact' - | 'clinical_or_translational_impact' - | 'societal_and_broader_impact'; - -// ── Request payloads ────────────────────────────── - -export interface CreateProposalReviewPayload { - unified_document_id: number; - grant_id?: number | null; -} - -export interface RfpBriefRefreshPayload { - force?: boolean; -} - -const BASE_PATH = '/api/ai_peer_review'; - -const NO_RFP_SUMMARY_DETAIL = 'No RFP summary yet.'; - -function isRfpSummaryNotFoundBody(body: Record): boolean { - return body.detail === NO_RFP_SUMMARY_DETAIL; -} - -export class AiPeerReviewService { - /** - * POST /api/ai_peer_review/proposal-review/ - */ - static async createProposalReview(payload: CreateProposalReviewPayload): Promise { - const raw = await ApiClient.post>( - `${BASE_PATH}/proposal-review/`, - payload - ); - return transformProposalReview(raw); - } - - /** - * GET /api/ai_peer_review/proposal-review// - */ - static async getProposalReview(reviewId: number | string): Promise { - const raw = await ApiClient.get>( - `${BASE_PATH}/proposal-review/${reviewId}/` - ); - return transformProposalReview(raw); - } - - /** - * GET /api/ai_peer_review/proposal-review/grant// - */ - static async getGrantComparison(grantId: number | string): Promise { - const raw = await ApiClient.get>( - `${BASE_PATH}/proposal-review/grant/${grantId}/` - ); - return transformGrantComparisonResponse(raw); - } - - /** - * GET /api/ai_peer_review/rfp// - * 404 with body `{ detail: "No RFP summary yet.", grant_id, status: null, ... }` → RfpSummaryMissing - */ - static async getRfpSummary(grantId: number | string): Promise { - try { - const raw = await ApiClient.get>(`${BASE_PATH}/rfp/${grantId}/`); - return transformRfpSummary(raw); - } catch (error) { - if (error instanceof ApiError && error.status === 404) { - const body = (error.errors ?? {}) as Record; - if (isRfpSummaryNotFoundBody(body)) { - const id = body.grant_id; - const gid = - typeof id === 'number' && Number.isFinite(id) - ? id - : typeof grantId === 'number' - ? grantId - : Number(grantId); - return { - grantId: Number.isFinite(gid) ? gid : 0, - status: null, - summaryContent: typeof body.summary_content === 'string' ? body.summary_content : '', - executiveComparisonSummary: - typeof body.executive_comparison_summary === 'string' - ? body.executive_comparison_summary - : '', - detail: typeof body.detail === 'string' ? body.detail : NO_RFP_SUMMARY_DETAIL, - }; - } - } - throw error; - } - } - - /** - * POST /api/ai_peer_review/rfp// - */ - static async refreshRfpSummary( - grantId: number | string, - payload?: RfpBriefRefreshPayload - ): Promise { - const raw = await ApiClient.post>( - `${BASE_PATH}/rfp/${grantId}/`, - payload ?? {} - ); - return transformRfpSummary(raw); - } - - /** - * POST /api/ai_peer_review/rfp//executive-summary/ - */ - static async generateExecutiveSummary( - grantId: number | string - ): Promise { - const raw = await ApiClient.post>( - `${BASE_PATH}/rfp/${grantId}/executive-summary/`, - {} - ); - return transformExecutiveSummary(raw); - } -} diff --git a/types/aiPeerReview.ts b/types/aiPeerReview.ts index 5c77654fc..7d1b489c0 100644 --- a/types/aiPeerReview.ts +++ b/types/aiPeerReview.ts @@ -1,114 +1,8 @@ -import { createTransformer } from './transformer'; -import type { - CategoryKey, - CategoryScoreLabel, - EditorialCategoryScore, - ItemDecisionValue, - OverallConfidence, - OverallRating, - ReviewStatus, - RfpStatus, -} from '@/services/aiPeerReview.service'; +import { createTransformer, type BaseTransformed } from './transformer'; -export type { - CategoryKey, - CategoryScoreLabel, - EditorialCategoryScore, - ItemDecisionValue, - OverallConfidence, - OverallRating, - ReviewStatus, - RfpStatus, -} from '@/services/aiPeerReview.service'; +export type ReviewStatus = 'pending' | 'processing' | 'completed' | 'failed'; -// ── Constants ─────────────────────────────────── - -export const CATEGORY_KEYS: readonly CategoryKey[] = [ - 'overall_impact', - 'importance_significance_innovation', - 'rigor_and_feasibility', - 'additional_review_criteria', - 'funding_opportunity_fit', - 'methods_rigor', - 'statistical_analysis_plan', - 'feasibility_and_execution', - 'scientific_impact', - 'clinical_or_translational_impact', - 'societal_and_broader_impact', -]; - -export const CATEGORY_ITEMS: Record = { - overall_impact: ['novelty', 'rigor', 'reproducibility', 'field_impact'], - importance_significance_innovation: [ - 'hypothesis_strength', - 'work_novelty', - 'question_importance', - 'advances_knowledge', - ], - rigor_and_feasibility: [ - 'study_design', - 'methodology', - 'timeline_feasibility', - 'team_qualifications', - 'research_environment', - 'budget_appropriateness_justification', - ], - additional_review_criteria: [ - 'human_or_animal_protections', - 'resubmission_critiques_addressed', - 'open_science_adherence', - 'ai_use_disclosed', - 'conflicts_of_interest_disclosed', - ], - funding_opportunity_fit: ['fit_modality', 'fit_aims', 'fit_deliverables', 'fit_scope'], - methods_rigor: [ - 'methods_detail', - 'parameters_specified', - 'controls_defined', - 'model_choice_justified', - 'outcomes_linked_to_aims', - ], - statistical_analysis_plan: [ - 'analysis_present', - 'power_analysis', - 'multiple_comparisons', - 'metrics_defined', - 'analysis_matches_design', - ], - feasibility_and_execution: [ - 'recruitment_feasible', - 'procedures_feasible', - 'timeline_milestones', - 'team_environment', - 'ethics_data_quality', - ], - scientific_impact: ['advances_understanding', 'generalizability', 'opens_new_directions'], - clinical_or_translational_impact: ['clinical_pathway', 'unmet_need', 'milestones_defined'], - societal_and_broader_impact: [ - 'societal_challenge', - 'public_communication', - 'commercial_potential', - ], -}; - -export const OPTIONAL_CATEGORIES: readonly CategoryKey[] = [ - 'statistical_analysis_plan', - 'clinical_or_translational_impact', - 'societal_and_broader_impact', -]; - -// ── Domain types ──────────────────────────────────────────────── - -export interface ItemDecision { - decision: ItemDecisionValue; - justification: string; -} - -export interface CategoryBlock { - score: CategoryScoreLabel; - rationale: string; - items: Record; -} +export type OverallRating = 'excellent' | 'good' | 'poor'; export type KeyInsightItemType = 'strength' | 'weakness'; @@ -125,100 +19,13 @@ export interface KeyInsightData { items: KeyInsightItem[]; } -export interface ProposalReviewResultData { - overallSummary: string; - overallRating: OverallRating | null; - overallRationale: string; - overallConfidence: OverallConfidence | null; - overallScoreNumeric: number | null; - fatalFlaws: string[]; - categories: Partial>; -} - -export interface EditorialCategory { - categoryCode: CategoryKey; - score: EditorialCategoryScore; -} - -export interface EditorialFeedback { - id: number; - unifiedDocumentId: number; - createdById: number | null; - updatedById: number | null; - categories: EditorialCategory[]; - expertInsights: string; - createdDate: string; - updatedDate: string; -} - -export interface ProposalReview { - id: number; - unifiedDocumentId: number; - grantId: number | null; - createdById: number | null; +type ProposalReviewFields = { status: ReviewStatus; overallRating: OverallRating | null; - overallRationale: string; - overallConfidence: OverallConfidence | null; - overallScoreNumeric: number | null; - resultData: ProposalReviewResultData | null; keyInsight: KeyInsightData | null; - errorMessage: string; - progress: number; - currentStep: string; - llmModel: string; - processingTime: number | null; - createdDate: string; - updatedDate: string; - editorialFeedback: EditorialFeedback | null; - alreadyExists?: boolean; -} - -export interface GrantComparisonRow { - unifiedDocumentId: number; - proposalTitle: string; - reviewId: number | null; - status: ReviewStatus | null; - overallRating: OverallRating | null; - overallScoreNumeric: number | null; - categories: Record | null; - editorialFeedback: EditorialFeedback | null; -} - -export interface GrantComparisonResponse { - grantId: number; - proposals: GrantComparisonRow[]; - executiveSummary: string; -} - -export interface RfpSummary { - id: number; - grantId: number; - status: RfpStatus; - summaryContent: string; - executiveComparisonSummary: string; - executiveComparisonUpdatedDate: string | null; - errorMessage: string; - llmModel: string; - processingTime: number | null; - createdDate: string; - updatedDate: string; - alreadyExists?: boolean; -} - -export interface RfpSummaryMissing { - grantId: number; - status: null; - summaryContent: string; - executiveComparisonSummary: string; - detail: string; -} +}; -export interface ExecutiveSummaryResponse { - grantId: number; - executiveSummary: string; - updatedDate: string; -} +export type ProposalReview = ProposalReviewFields & BaseTransformed; // ── Helpers ───────────────────────────────────────────────────────────────── @@ -232,38 +39,6 @@ function str(v: unknown, fallback = ''): string { return typeof v === 'string' ? v : fallback; } -function transformItemDecision(raw: Record | null | undefined): ItemDecision { - if (!raw || typeof raw !== 'object') { - return { decision: 'N/A', justification: '' }; - } - const decision = (raw.decision ?? raw.item_decision) as ItemDecisionValue | undefined; - const allowed: ItemDecisionValue[] = ['Yes', 'No', 'Partial', 'N/A']; - return { - decision: decision && allowed.includes(decision) ? decision : 'N/A', - justification: str(raw.justification ?? raw.justification_text), - }; -} - -function transformCategoryBlock( - raw: Record | null | undefined -): CategoryBlock | null { - if (!raw || typeof raw !== 'object') return null; - const score = raw.score as CategoryScoreLabel | undefined; - const allowedScores: CategoryScoreLabel[] = ['High', 'Medium', 'Low', 'N/A']; - const itemsRaw = raw.items; - const items: Record = {}; - if (itemsRaw && typeof itemsRaw === 'object' && !Array.isArray(itemsRaw)) { - for (const [key, val] of Object.entries(itemsRaw as Record)) { - items[key] = transformItemDecision(val as Record); - } - } - return { - score: score && allowedScores.includes(score) ? score : 'N/A', - rationale: str(raw.rationale), - items, - }; -} - function transformKeyInsightItemRow(raw: Record): KeyInsightItem | null { const itemTypeRaw = raw.item_type; if (itemTypeRaw !== 'strength' && itemTypeRaw !== 'weakness') return null; @@ -290,214 +65,24 @@ export function transformKeyInsight(raw: unknown): KeyInsightData | null { items.sort((a, b) => a.order - b.order || a.id - b.id); const tldr = str(o.tldr); if (items.length === 0 && !tldr) return null; - return { tldr, items }; -} - -export function transformResultData(raw: unknown): ProposalReviewResultData | null { - if (raw == null || typeof raw !== 'object') return null; - const o = raw as Record; - const keys = Object.keys(o); - if (keys.length === 0) return null; - const categoriesRaw = o.categories; - const categories: Partial> = {}; - if (categoriesRaw && typeof categoriesRaw === 'object' && !Array.isArray(categoriesRaw)) { - const categoriesRecord = categoriesRaw as Record; - for (const key of Object.keys(categoriesRecord)) { - if (!(CATEGORY_KEYS as readonly string[]).includes(key)) continue; - const block = transformCategoryBlock(categoriesRecord[key] as Record); - if (block) categories[key as CategoryKey] = block; - } - } - - const or = o.overall_rating as OverallRating | undefined; - const ratings: OverallRating[] = ['excellent', 'good', 'poor']; - const oc = o.overall_confidence as OverallConfidence | undefined; - const confidences: OverallConfidence[] = ['High', 'Medium', 'Low']; - - return { - overallSummary: str(o.overall_summary), - overallRating: or && ratings.includes(or) ? or : null, - overallRationale: str(o.overall_rationale), - overallConfidence: oc && confidences.includes(oc) ? oc : null, - overallScoreNumeric: numOrNull(o.overall_score_numeric), - fatalFlaws: Array.isArray(o.fatal_flaws) - ? (o.fatal_flaws as unknown[]).map((x) => String(x)) - : [], - categories, - }; -} - -function transformEditorialCategory(raw: Record): EditorialCategory | null { - const code = raw.category_code as string | undefined; - if (!code || !(CATEGORY_KEYS as readonly string[]).includes(code)) return null; - const scoreRaw = (raw.score as string | undefined)?.toLowerCase(); - const scores: EditorialCategoryScore[] = ['high', 'medium', 'low']; - const score = - scoreRaw && scores.includes(scoreRaw as EditorialCategoryScore) - ? (scoreRaw as EditorialCategoryScore) - : 'low'; - return { categoryCode: code as CategoryKey, score }; -} - -export function transformEditorialFeedback(raw: unknown): EditorialFeedback | null { - if (raw == null || typeof raw !== 'object') return null; - const o = raw as Record; - const catsRaw = o.categories; - const categories: EditorialCategory[] = []; - if (Array.isArray(catsRaw)) { - for (const row of catsRaw) { - if (row && typeof row === 'object') { - const c = transformEditorialCategory(row as Record); - if (c) categories.push(c); - } - } - } - return { - id: numOrNull(o.id) ?? 0, - unifiedDocumentId: numOrNull(o.unified_document_id) ?? 0, - createdById: numOrNull(o.created_by_id), - updatedById: numOrNull(o.updated_by_id), - categories, - expertInsights: str(o.expert_insights), - createdDate: str(o.created_date), - updatedDate: str(o.updated_date), - }; + return { tldr, items }; } const reviewStatuses: ReviewStatus[] = ['pending', 'processing', 'completed', 'failed']; -const rfpStatuses: RfpStatus[] = ['pending', 'processing', 'completed', 'failed']; function parseReviewStatus(v: unknown): ReviewStatus { return reviewStatuses.includes(v as ReviewStatus) ? (v as ReviewStatus) : 'pending'; } -function parseRfpStatus(v: unknown): RfpStatus { - return rfpStatuses.includes(v as RfpStatus) ? (v as RfpStatus) : 'pending'; -} - function parseOverallRating(v: unknown): OverallRating | null { if (v == null || v === '') return null; const r = String(v).toLowerCase() as OverallRating; return ['excellent', 'good', 'poor'].includes(r) ? r : null; } -function parseOverallConfidence(v: unknown): OverallConfidence | null { - if (v == null || v === '') return null; - const c = v as OverallConfidence; - return ['High', 'Medium', 'Low'].includes(c) ? c : null; -} - -export const transformProposalReview = createTransformer((raw) => { - const status = parseReviewStatus(raw.status); - const rd = transformResultData(raw.result_data); - const keyInsight = transformKeyInsight(raw.key_insight); - - return { - id: numOrNull(raw.id) ?? 0, - unifiedDocumentId: numOrNull(raw.unified_document_id) ?? 0, - grantId: numOrNull(raw.grant_id), - createdById: numOrNull(raw.created_by_id), - status, - overallRating: parseOverallRating(raw.overall_rating), - overallRationale: str(raw.overall_rationale), - overallConfidence: parseOverallConfidence(raw.overall_confidence), - overallScoreNumeric: numOrNull(raw.overall_score_numeric), - resultData: rd, - keyInsight, - errorMessage: str(raw.error_message), - progress: Number.isFinite(Number(raw.progress)) ? Number(raw.progress) : 0, - currentStep: str(raw.current_step), - llmModel: str(raw.llm_model), - processingTime: (() => { - const p = raw.processing_time; - if (p == null || p === '') return null; - const n = Number(p); - return Number.isFinite(n) ? n : null; - })(), - createdDate: str(raw.created_date), - updatedDate: str(raw.updated_date), - editorialFeedback: transformEditorialFeedback(raw.editorial_feedback), - }; -}); - -function transformComparisonCategories( - raw: unknown -): Record | null { - if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) return null; - const out = {} as Record; - const allowed: CategoryScoreLabel[] = ['High', 'Medium', 'Low', 'N/A']; - for (const key of CATEGORY_KEYS) { - const v = (raw as Record)[key]; - if (v == null) { - out[key] = null; - } else if ( - typeof v === 'string' && - (allowed.includes(v as CategoryScoreLabel) || v === 'N/A') - ) { - out[key] = v as CategoryScoreLabel; - } else { - out[key] = null; - } - } - return out; -} - -export const transformGrantComparisonRow = createTransformer((raw) => { - const st = raw.status; - return { - unifiedDocumentId: numOrNull(raw.unified_document_id) ?? 0, - proposalTitle: str(raw.proposal_title), - reviewId: numOrNull(raw.review_id), - status: st == null || st === '' ? null : parseReviewStatus(st), - overallRating: parseOverallRating(raw.overall_rating), - overallScoreNumeric: numOrNull(raw.overall_score_numeric), - categories: transformComparisonCategories(raw.categories), - editorialFeedback: transformEditorialFeedback(raw.editorial_feedback), - }; -}); - -export const transformGrantComparisonResponse = createTransformer( - (raw) => ({ - grantId: numOrNull(raw.grant_id) ?? 0, - proposals: Array.isArray(raw.proposals) - ? raw.proposals.map((p: any) => transformGrantComparisonRow(p)) - : [], - executiveSummary: str(raw.executive_summary), - }) -); - -export const transformRfpSummary = createTransformer((raw) => ({ - id: numOrNull(raw.id) ?? 0, - grantId: numOrNull(raw.grant_id) ?? 0, - status: parseRfpStatus(raw.status), - summaryContent: str(raw.summary_content), - executiveComparisonSummary: str(raw.executive_comparison_summary), - executiveComparisonUpdatedDate: - raw.executive_comparison_updated_date != null && raw.executive_comparison_updated_date !== '' - ? str(raw.executive_comparison_updated_date) - : null, - errorMessage: str(raw.error_message), - llmModel: str(raw.llm_model), - processingTime: (() => { - const p = raw.processing_time; - if (p == null || p === '') return null; - const n = Number(p); - return Number.isFinite(n) ? n : null; - })(), - createdDate: str(raw.created_date), - updatedDate: str(raw.updated_date), - alreadyExists: raw.already_exists !== undefined ? Boolean(raw.already_exists) : undefined, +export const transformProposalReview = createTransformer((raw) => ({ + status: parseReviewStatus(raw.status), + overallRating: parseOverallRating(raw.overall_rating), + keyInsight: transformKeyInsight(raw.key_insight), })); - -export const transformExecutiveSummary = createTransformer( - (raw) => ({ - grantId: numOrNull(raw.grant_id) ?? 0, - executiveSummary: str(raw.executive_summary), - updatedDate: str(raw.updated_date), - }) -); - -export function isRfpSummaryMissing(x: RfpSummary | RfpSummaryMissing): x is RfpSummaryMissing { - return x.status === null; -}