feat(ai): unify AI architecture, implement RAG and legacy migration
CI / CD Pipeline / build (push) Failing after 5m36s
CI / CD Pipeline / deploy (push) Has been skipped

This commit is contained in:
2026-05-15 11:10:44 +07:00
parent 0240d80da5
commit 6cb3ae10ee
56 changed files with 6051 additions and 304 deletions
@@ -0,0 +1,433 @@
// File: app/(dashboard)/ai-staging/page.tsx
// Change Log
// - 2026-05-14: เพิ่มหน้า AI staging queue สำหรับ human-in-the-loop review.
'use client';
import { useMemo, useState } from 'react';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { CheckCircle2, RefreshCcw } from 'lucide-react';
import {
AiStagingRecord,
AiStagingStatus,
useAiStagingQueue,
useApproveAiStagingRecord,
} from '@/lib/api/ai';
import { projectService } from '@/lib/services/project.service';
import { masterDataService } from '@/lib/services/master-data.service';
import { organizationService } from '@/lib/services/organization.service';
import { useQuery } from '@tanstack/react-query';
import { AiStatusBanner } from '@/components/ai/AiStatusBanner';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { useTranslations } from '@/hooks/use-translations';
interface ProjectOption {
publicId?: string;
projectCode?: string;
projectName?: string;
}
interface OrganizationOption {
publicId?: string;
organizationCode?: string;
organizationName?: string;
}
interface CorrespondenceTypeOption {
typeCode: string;
typeName: string;
}
const approveSchema = z.object({
documentNumber: z.string().min(1),
subject: z.string().min(1),
categoryCode: z.string().min(1),
projectPublicId: z.string().uuid(),
senderOrganizationPublicId: z.string().uuid().optional(),
receiverOrganizationPublicId: z.string().uuid().optional(),
issuedDate: z.string().optional(),
receivedDate: z.string().optional(),
body: z.string().optional(),
});
type ApproveFormValues = z.infer<typeof approveSchema>;
const getMetadataText = (
metadata: Record<string, unknown> | undefined,
keys: string[]
): string => {
for (const key of keys) {
const value = metadata?.[key];
if (typeof value === 'string') return value;
}
return '';
};
function getStatusVariant(
status: AiStagingStatus
): 'default' | 'secondary' | 'destructive' | 'outline' {
if (status === AiStagingStatus.PENDING) return 'secondary';
if (status === AiStagingStatus.REJECTED) return 'destructive';
if (status === AiStagingStatus.IMPORTED) return 'default';
return 'outline';
}
export default function AiStagingPage() {
const t = useTranslations();
const [selectedRecord, setSelectedRecord] = useState<AiStagingRecord | null>(
null
);
const queueQuery = useAiStagingQueue();
const approveMutation = useApproveAiStagingRecord();
const projectsQuery = useQuery({
queryKey: ['ai-staging', 'projects'],
queryFn: () => projectService.getAll({ isActive: true, limit: 100 }),
});
const organizationsQuery = useQuery({
queryKey: ['ai-staging', 'organizations'],
queryFn: () => organizationService.getAll({ isActive: true, limit: 200 }),
});
const typesQuery = useQuery({
queryKey: ['ai-staging', 'correspondence-types'],
queryFn: () => masterDataService.getCorrespondenceTypes(),
});
const form = useForm<ApproveFormValues>({
resolver: zodResolver(approveSchema),
defaultValues: {
documentNumber: '',
subject: '',
categoryCode: '',
projectPublicId: '',
senderOrganizationPublicId: undefined,
receiverOrganizationPublicId: undefined,
issuedDate: '',
receivedDate: '',
body: '',
},
});
const records = queueQuery.data?.items ?? [];
const projects = useMemo(
() => (Array.isArray(projectsQuery.data) ? (projectsQuery.data as ProjectOption[]) : []),
[projectsQuery.data]
);
const organizations = useMemo(
() =>
Array.isArray(organizationsQuery.data)
? (organizationsQuery.data as OrganizationOption[])
: [],
[organizationsQuery.data]
);
const correspondenceTypes = useMemo(
() =>
Array.isArray(typesQuery.data)
? (typesQuery.data as CorrespondenceTypeOption[])
: [],
[typesQuery.data]
);
const openApprovalDialog = (record: AiStagingRecord): void => {
const metadata = record.extractedMetadata;
setSelectedRecord(record);
form.reset({
documentNumber: getMetadataText(metadata, ['documentNumber', 'doc_number']),
subject: getMetadataText(metadata, ['subject', 'title']),
categoryCode: getMetadataText(metadata, ['categoryCode', 'category']),
projectPublicId: '',
senderOrganizationPublicId: undefined,
receiverOrganizationPublicId: undefined,
issuedDate: getMetadataText(metadata, ['issuedDate', 'issued_date']),
receivedDate: getMetadataText(metadata, ['receivedDate', 'received_date']),
body: getMetadataText(metadata, ['body', 'summary']),
});
};
const onSubmit = async (values: ApproveFormValues): Promise<void> => {
if (!selectedRecord) return;
try {
await approveMutation.mutateAsync({
publicId: selectedRecord.publicId,
payload: {
...values,
finalMetadata: values,
},
});
toast.success(t('ai.staging.approveSuccess'));
setSelectedRecord(null);
} catch {
toast.error(t('ai.staging.approveError'));
}
};
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-normal">
{t('ai.staging.title')}
</h1>
<p className="text-sm text-muted-foreground">
{t('ai.staging.subtitle')}
</p>
</div>
<Button
type="button"
variant="outline"
onClick={() => void queueQuery.refetch()}
disabled={queueQuery.isFetching}
>
<RefreshCcw className="mr-2 h-4 w-4" />
{t('ai.staging.refresh')}
</Button>
</div>
<AiStatusBanner isOffline={queueQuery.isError} />
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('ai.staging.file')}</TableHead>
<TableHead>{t('ai.staging.batch')}</TableHead>
<TableHead>{t('ai.staging.confidence')}</TableHead>
<TableHead>{t('ai.staging.status')}</TableHead>
<TableHead className="w-[120px]" />
</TableRow>
</TableHeader>
<TableBody>
{records.map((record) => (
<TableRow key={record.publicId}>
<TableCell className="font-medium">
{record.originalFileName}
{record.errorReason ? (
<p className="text-xs text-destructive">
{record.errorReason}
</p>
) : null}
</TableCell>
<TableCell>{record.batchId}</TableCell>
<TableCell>
{record.confidenceScore === undefined
? t('ai.staging.empty')
: `${Math.round(record.confidenceScore * 100)}%`}
</TableCell>
<TableCell>
<Badge variant={getStatusVariant(record.status)}>
{record.status}
</Badge>
</TableCell>
<TableCell>
<Button
type="button"
size="sm"
disabled={record.status !== AiStagingStatus.PENDING}
onClick={() => openApprovalDialog(record)}
>
<CheckCircle2 className="mr-2 h-4 w-4" />
{t('ai.staging.review')}
</Button>
</TableCell>
</TableRow>
))}
{records.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="h-24 text-center text-muted-foreground">
{queueQuery.isLoading
? t('ai.staging.loading')
: t('ai.staging.emptyQueue')}
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</div>
<Dialog
open={selectedRecord !== null}
onOpenChange={(open) => {
if (!open) setSelectedRecord(null);
}}
>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{t('ai.staging.reviewTitle')}</DialogTitle>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="documentNumber">
{t('ai.staging.documentNumber')}
</Label>
<Input id="documentNumber" {...form.register('documentNumber')} />
</div>
<div className="space-y-2">
<Label>{t('ai.staging.category')}</Label>
<Select
value={form.watch('categoryCode')}
onValueChange={(value) =>
form.setValue('categoryCode', value, { shouldValidate: true })
}
>
<SelectTrigger>
<SelectValue placeholder={t('ai.staging.selectCategory')} />
</SelectTrigger>
<SelectContent>
{correspondenceTypes.map((type) => (
<SelectItem key={type.typeCode} value={type.typeCode}>
{type.typeName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="subject">{t('ai.staging.subject')}</Label>
<Input id="subject" {...form.register('subject')} />
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>{t('ai.staging.project')}</Label>
<Select
value={form.watch('projectPublicId')}
onValueChange={(value) =>
form.setValue('projectPublicId', value, {
shouldValidate: true,
})
}
>
<SelectTrigger>
<SelectValue placeholder={t('ai.staging.selectProject')} />
</SelectTrigger>
<SelectContent>
{projects.map((project) => (
<SelectItem
key={project.publicId ?? project.projectCode}
value={project.publicId ?? ''}
>
{project.projectName ?? project.projectCode}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t('ai.staging.sender')}</Label>
<Select
value={form.watch('senderOrganizationPublicId') ?? ''}
onValueChange={(value) =>
form.setValue('senderOrganizationPublicId', value, {
shouldValidate: true,
})
}
>
<SelectTrigger>
<SelectValue placeholder={t('ai.staging.selectSender')} />
</SelectTrigger>
<SelectContent>
{organizations.map((organization) => (
<SelectItem
key={organization.publicId ?? organization.organizationCode}
value={organization.publicId ?? ''}
>
{organization.organizationName ??
organization.organizationCode}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>{t('ai.staging.receiver')}</Label>
<Select
value={form.watch('receiverOrganizationPublicId') ?? ''}
onValueChange={(value) =>
form.setValue('receiverOrganizationPublicId', value, {
shouldValidate: true,
})
}
>
<SelectTrigger>
<SelectValue placeholder={t('ai.staging.selectReceiver')} />
</SelectTrigger>
<SelectContent>
{organizations.map((organization) => (
<SelectItem
key={organization.publicId ?? organization.organizationCode}
value={organization.publicId ?? ''}
>
{organization.organizationName ??
organization.organizationCode}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="issuedDate">{t('ai.staging.issuedDate')}</Label>
<Input id="issuedDate" type="date" {...form.register('issuedDate')} />
</div>
<div className="space-y-2">
<Label htmlFor="receivedDate">
{t('ai.staging.receivedDate')}
</Label>
<Input
id="receivedDate"
type="date"
{...form.register('receivedDate')}
/>
</div>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="body">{t('ai.staging.body')}</Label>
<Textarea id="body" rows={5} {...form.register('body')} />
</div>
<DialogFooter>
<Button type="submit" disabled={approveMutation.isPending}>
{t('ai.staging.approve')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
// File: components/ai/AiStatusBanner.tsx
// Change Log
// - 2026-05-14: เพิ่ม banner สำหรับ graceful degradation ของ AI staging.
'use client';
import { AlertTriangle, CheckCircle2 } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { useTranslations } from '@/hooks/use-translations';
interface AiStatusBannerProps {
isOffline: boolean;
}
export function AiStatusBanner({ isOffline }: AiStatusBannerProps) {
const t = useTranslations();
if (isOffline) {
return (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>{t('ai.status.offlineTitle')}</AlertTitle>
<AlertDescription>{t('ai.status.offlineDescription')}</AlertDescription>
</Alert>
);
}
return (
<Alert>
<CheckCircle2 className="h-4 w-4" />
<AlertTitle>{t('ai.status.onlineTitle')}</AlertTitle>
<AlertDescription>{t('ai.status.onlineDescription')}</AlertDescription>
</Alert>
);
}
+293
View File
@@ -0,0 +1,293 @@
// File: components/ai/RagChatWidget.tsx
// Change Log
// - 2026-05-14: เพิ่ม RAG Chat Widget พร้อม BullMQ polling UI ตาม ADR-023 Phase 4 (T023).
'use client';
import { useState, useRef, useEffect } from 'react';
import { Send, X, Loader2, AlertTriangle, BookOpen } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
AiRagCitation,
AiRagJobResult,
useCancelRagJob,
useRagJobStatus,
useSubmitRagQuery,
} from '@/lib/api/ai';
interface RagChatWidgetProps {
/** publicId ของโครงการสำหรับ project-scoped vector search (FR-002) */
projectPublicId: string;
/** แสดง widget ในโหมด disabled เมื่อ AI host offline (FR-006) */
isAiOffline?: boolean;
}
/** แปลง status เป็น badge variant */
function statusBadge(status: AiRagJobResult['status']): {
label: string;
variant: 'default' | 'secondary' | 'destructive' | 'outline';
} {
switch (status) {
case 'pending':
return { label: 'รอในคิว...', variant: 'outline' };
case 'processing':
return { label: 'กำลังประมวลผล...', variant: 'secondary' };
case 'completed':
return { label: 'เสร็จสิ้น', variant: 'default' };
case 'failed':
return { label: 'ล้มเหลว', variant: 'destructive' };
case 'cancelled':
return { label: 'ยกเลิกแล้ว', variant: 'outline' };
default:
return { label: status, variant: 'outline' };
}
}
/**
* Widget สำหรับ RAG Conversational Q&A ผ่าน BullMQ polling (ADR-023 FR-009, FR-010, FR-011)
* - ส่งคำถามเข้า /api/ai/rag/query → รับ requestPublicId
* - Polling /api/ai/rag/jobs/:requestPublicId ทุก 2 วินาที
* - แสดง status: pending → processing → completed/failed
* - รองรับการยกเลิก job (FR-011)
*/
export function RagChatWidget({ projectPublicId, isAiOffline = false }: RagChatWidgetProps) {
const [question, setQuestion] = useState('');
const [activeRequestId, setActiveRequestId] = useState<string | null>(null);
const [isPolling, setIsPolling] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const submitMutation = useSubmitRagQuery();
const cancelMutation = useCancelRagJob();
const { data: jobResult } = useRagJobStatus(activeRequestId, isPolling);
// หยุด polling เมื่อ job เสร็จ/ล้มเหลว/ยกเลิก
useEffect(() => {
if (!jobResult) return;
if (
jobResult.status === 'completed' ||
jobResult.status === 'failed' ||
jobResult.status === 'cancelled'
) {
setIsPolling(false);
if (jobResult.status === 'failed') {
toast.error('การค้นหาล้มเหลว กรุณาลองใหม่อีกครั้ง');
}
}
}, [jobResult]);
const handleSubmit = async () => {
const trimmed = question.trim();
if (!trimmed || isAiOffline || submitMutation.isPending) return;
try {
const result = await submitMutation.mutateAsync({
question: trimmed,
projectPublicId,
});
setActiveRequestId(result.requestPublicId);
setIsPolling(true);
} catch {
toast.error('ไม่สามารถส่งคำถามได้ กรุณาลองใหม่');
}
};
const handleCancel = async () => {
if (!activeRequestId) return;
try {
await cancelMutation.mutateAsync(activeRequestId);
setIsPolling(false);
} catch {
toast.error('ไม่สามารถยกเลิกได้');
}
};
const handleReset = () => {
setQuestion('');
setActiveRequestId(null);
setIsPolling(false);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
void handleSubmit();
}
};
const isActive = submitMutation.isPending || isPolling;
const showResult = !!jobResult && (jobResult.status === 'completed' || jobResult.status === 'failed');
return (
<Card className="w-full">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<BookOpen className="h-4 w-4" />
RAG Q&amp;A
{isAiOffline && (
<Badge variant="destructive" className="ml-auto text-xs">
AI Offline
</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{isAiOffline && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
AI RAG (FR-006)
</AlertDescription>
</Alert>
)}
{/* Input Area */}
<div className="space-y-2">
<Textarea
ref={textareaRef}
value={question}
onChange={(e) => setQuestion(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="พิมพ์คำถามเกี่ยวกับเอกสารโครงการ... (Ctrl+Enter เพื่อส่ง)"
className="min-h-[80px] resize-none"
maxLength={500}
disabled={isAiOffline || isActive}
/>
<p className="text-right text-xs text-muted-foreground">
{question.length}/500
</p>
</div>
{/* Job Status Indicator */}
{activeRequestId && jobResult && (
<div className="rounded-md border p-3 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{(jobResult.status === 'pending' || jobResult.status === 'processing') && (
<Loader2 className="h-3 w-3 animate-spin" />
)}
<Badge variant={statusBadge(jobResult.status).variant}>
{statusBadge(jobResult.status).label}
</Badge>
</div>
{jobResult.status !== 'completed' && jobResult.status !== 'failed' && (
<Button
variant="ghost"
size="sm"
onClick={() => void handleCancel()}
disabled={cancelMutation.isPending}
className="h-6 px-2 text-xs"
>
<X className="h-3 w-3 mr-1" />
</Button>
)}
</div>
{/* Answer */}
{showResult && jobResult.answer && (
<div className="space-y-2">
<p className="text-sm font-medium">:</p>
<p className="text-sm text-muted-foreground whitespace-pre-wrap leading-relaxed">
{jobResult.answer}
</p>
{typeof jobResult.confidence === 'number' && (
<p className="text-xs text-muted-foreground">
: {(jobResult.confidence * 100).toFixed(1)}%
{jobResult.usedFallbackModel && ' (Fallback Model)'}
</p>
)}
</div>
)}
{/* Error */}
{showResult && jobResult.status === 'failed' && (
<p className="text-sm text-destructive">{jobResult.errorMessage ?? 'เกิดข้อผิดพลาดที่ไม่ทราบสาเหตุ'}</p>
)}
{/* Citations */}
{showResult && jobResult.citations && jobResult.citations.length > 0 && (
<CitationList citations={jobResult.citations} />
)}
</div>
)}
</CardContent>
<CardFooter className="flex gap-2 pt-0">
{!showResult ? (
<Button
onClick={() => void handleSubmit()}
disabled={!question.trim() || isAiOffline || isActive}
size="sm"
className="ml-auto"
>
{isActive ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<Send className="h-4 w-4 mr-2" />
)}
{isActive ? 'กำลังค้นหา...' : 'ส่งคำถาม'}
</Button>
) : (
<Button variant="outline" size="sm" onClick={handleReset} className="ml-auto">
</Button>
)}
</CardFooter>
</Card>
);
}
/** แสดง citations จาก RAG results */
function CitationList({ citations }: { citations: AiRagCitation[] }) {
const [expanded, setExpanded] = useState(false);
const visible = expanded ? citations : citations.slice(0, 3);
return (
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground">
({citations.length} ):
</p>
{visible.map((c, index) => (
<div
key={`${String(c.pointId)}-${index}`}
className="rounded border border-border bg-muted/40 p-2 text-xs space-y-0.5"
>
<div className="flex items-center justify-between gap-2">
<span className="font-medium">
{c.docType ?? 'เอกสาร'}
{c.docNumber ? `${c.docNumber}` : ''}
</span>
<span className="text-muted-foreground shrink-0">
{(c.score * 100).toFixed(1)}%
</span>
</div>
{c.snippet && (
<p className="text-muted-foreground line-clamp-2">{c.snippet}</p>
)}
</div>
))}
{citations.length > 3 && (
<Button
variant="ghost"
size="sm"
className="h-6 px-1 text-xs w-full"
onClick={() => setExpanded(!expanded)}
>
{expanded ? 'แสดงน้อยลง' : `ดูอีก ${citations.length - 3} รายการ`}
</Button>
)}
</div>
);
}
+178
View File
@@ -0,0 +1,178 @@
// File: lib/api/ai.ts
// Change Log
// - 2026-05-14: เพิ่ม hooks สำหรับ AI staging queue ตาม ADR-023.
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import apiClient from '@/lib/api/client';
export enum AiStagingStatus {
PENDING = 'PENDING',
IMPORTED = 'IMPORTED',
REJECTED = 'REJECTED',
}
export interface AiStagingRecord {
publicId: string;
batchId: string;
originalFileName: string;
sourceAttachmentPublicId?: string;
extractedMetadata?: Record<string, unknown>;
confidenceScore?: number;
status: AiStagingStatus;
errorReason?: string;
createdAt: string;
updatedAt: string;
}
export interface AiStagingQueueResponse {
items: AiStagingRecord[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface ApproveAiStagingPayload {
documentNumber: string;
subject: string;
categoryCode: string;
projectPublicId: string;
senderOrganizationPublicId?: string;
receiverOrganizationPublicId?: string;
issuedDate?: string;
receivedDate?: string;
body?: string;
finalMetadata?: Record<string, unknown>;
}
interface WrappedData<T> {
data?: T;
}
const extractData = <T>(value: unknown): T => {
let current: unknown = value;
for (let index = 0; index < 5; index += 1) {
if (!current || typeof current !== 'object' || !('data' in current)) {
return current as T;
}
current = (current as WrappedData<unknown>).data;
}
return current as T;
};
export const aiStagingKeys = {
all: ['ai-staging'] as const,
queue: (status?: AiStagingStatus) =>
[...aiStagingKeys.all, 'queue', status ?? 'ALL'] as const,
};
export function useAiStagingQueue(status?: AiStagingStatus) {
return useQuery({
queryKey: aiStagingKeys.queue(status),
queryFn: async (): Promise<AiStagingQueueResponse> => {
const response = await apiClient.get('/ai/legacy-migration/queue', {
params: { status, page: 1, limit: 50 },
});
return extractData<AiStagingQueueResponse>(response.data);
},
staleTime: 30 * 1000,
});
}
// ─── RAG Query Hooks (Phase 4) ────────────────────────────────────────────────
export type RagJobStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled' | 'not_found';
export interface AiRagCitation {
pointId: string | number;
score: number;
docType?: string;
docNumber?: string;
snippet?: string;
}
export interface AiRagJobResult {
requestPublicId: string;
status: RagJobStatus;
answer?: string;
citations?: AiRagCitation[];
confidence?: number;
usedFallbackModel?: boolean;
errorMessage?: string;
completedAt?: string;
}
export interface SubmitRagQueryPayload {
question: string;
projectPublicId: string;
}
export const ragQueryKeys = {
all: ['ai-rag'] as const,
job: (requestPublicId: string) => [...ragQueryKeys.all, 'job', requestPublicId] as const,
};
export function useSubmitRagQuery() {
return useMutation({
mutationFn: async (payload: SubmitRagQueryPayload): Promise<{ requestPublicId: string; jobId: string; status: string }> => {
const response = await apiClient.post('/ai/rag/query', payload, {
headers: { 'Idempotency-Key': `rag-${Date.now()}` },
});
return extractData<{ requestPublicId: string; jobId: string; status: string }>(response.data);
},
});
}
export function useRagJobStatus(requestPublicId: string | null, enabled: boolean) {
return useQuery({
queryKey: ragQueryKeys.job(requestPublicId ?? ''),
queryFn: async (): Promise<AiRagJobResult> => {
const response = await apiClient.get(`/ai/rag/jobs/${requestPublicId}`);
return extractData<AiRagJobResult>(response.data);
},
enabled: enabled && !!requestPublicId,
refetchInterval: (query) => {
const status = query.state.data?.status;
if (status === 'completed' || status === 'failed' || status === 'cancelled') return false;
return 2000;
},
staleTime: 0,
});
}
export function useCancelRagJob() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (requestPublicId: string): Promise<void> => {
await apiClient.delete(`/ai/rag/jobs/${requestPublicId}`);
},
onSuccess: (_data, requestPublicId) => {
void queryClient.invalidateQueries({ queryKey: ragQueryKeys.job(requestPublicId) });
},
});
}
export function useApproveAiStagingRecord() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
publicId,
payload,
}: {
publicId: string;
payload: ApproveAiStagingPayload;
}) => {
const response = await apiClient.post(
`/ai/legacy-migration/queue/${publicId}/approve`,
payload
);
return extractData<{ record: AiStagingRecord; importResult: unknown }>(
response.data
);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: aiStagingKeys.all });
},
});
}
+1
View File
@@ -74,6 +74,7 @@
"@typescript-eslint/eslint-plugin": "^8.57.1",
"@typescript-eslint/parser": "^8.57.1",
"@vitejs/plugin-react": "^5.2.0",
"@vitest/coverage-v8": "^4.1.6",
"autoprefixer": "^10.4.27",
"baseline-browser-mapping": "^2.10.8",
"eslint": "^9.39.1",
+34 -1
View File
@@ -37,5 +37,38 @@
"filePreview.unsupported": "Preview is not available for this file type.",
"filePreview.loadError": "Unable to load file. Please try again.",
"filePreview.download": "Download",
"filePreview.close": "Close"
"filePreview.close": "Close",
"ai.status.offlineTitle": "AI unavailable",
"ai.status.offlineDescription": "AI staging is temporarily unavailable. Manual document operations remain available.",
"ai.status.onlineTitle": "AI staging available",
"ai.status.onlineDescription": "Legacy migration review queue is connected.",
"ai.staging.title": "AI Staging Queue",
"ai.staging.subtitle": "Review AI-extracted legacy metadata before committing it to DMS records.",
"ai.staging.refresh": "Refresh",
"ai.staging.file": "File",
"ai.staging.batch": "Batch",
"ai.staging.confidence": "Confidence",
"ai.staging.status": "Status",
"ai.staging.review": "Review",
"ai.staging.empty": "—",
"ai.staging.loading": "Loading staging records...",
"ai.staging.emptyQueue": "No staging records found.",
"ai.staging.reviewTitle": "Review AI Metadata",
"ai.staging.documentNumber": "Document number",
"ai.staging.category": "Category",
"ai.staging.selectCategory": "Select category",
"ai.staging.subject": "Subject",
"ai.staging.project": "Project",
"ai.staging.selectProject": "Select project",
"ai.staging.sender": "Sender",
"ai.staging.selectSender": "Select sender",
"ai.staging.receiver": "Receiver",
"ai.staging.selectReceiver": "Select receiver",
"ai.staging.issuedDate": "Issued date",
"ai.staging.receivedDate": "Received date",
"ai.staging.body": "Body",
"ai.staging.approve": "Approve",
"ai.staging.approveSuccess": "Staging record approved.",
"ai.staging.approveError": "Unable to approve staging record."
}
+34 -1
View File
@@ -37,5 +37,38 @@
"filePreview.unsupported": "ไม่รองรับการแสดงผลสำหรับไฟล์ประเภทนี้",
"filePreview.loadError": "ไม่สามารถโหลดไฟล์ได้ กรุณาลองใหม่",
"filePreview.download": "ดาวน์โหลด",
"filePreview.close": "ปิด"
"filePreview.close": "ปิด",
"ai.status.offlineTitle": "ระบบ AI ไม่พร้อมใช้งาน",
"ai.status.offlineDescription": "ไม่สามารถเชื่อมต่อ staging queue ของ AI ได้ชั่วคราว แต่ยังทำงานเอกสารแบบ manual ได้ตามปกติ",
"ai.status.onlineTitle": "ระบบ AI พร้อมใช้งาน",
"ai.status.onlineDescription": "เชื่อมต่อคิวตรวจสอบข้อมูลเอกสารเก่าเรียบร้อยแล้ว",
"ai.staging.title": "คิวตรวจสอบ AI",
"ai.staging.subtitle": "ตรวจสอบ metadata จาก AI ก่อนบันทึกเข้า DMS",
"ai.staging.refresh": "รีเฟรช",
"ai.staging.file": "ไฟล์",
"ai.staging.batch": "Batch",
"ai.staging.confidence": "ความมั่นใจ",
"ai.staging.status": "สถานะ",
"ai.staging.review": "ตรวจสอบ",
"ai.staging.empty": "—",
"ai.staging.loading": "กำลังโหลดรายการ...",
"ai.staging.emptyQueue": "ยังไม่มีรายการในคิว",
"ai.staging.reviewTitle": "ตรวจสอบ Metadata จาก AI",
"ai.staging.documentNumber": "เลขที่เอกสาร",
"ai.staging.category": "ประเภท",
"ai.staging.selectCategory": "เลือกประเภท",
"ai.staging.subject": "เรื่อง",
"ai.staging.project": "โครงการ",
"ai.staging.selectProject": "เลือกโครงการ",
"ai.staging.sender": "ผู้ส่ง",
"ai.staging.selectSender": "เลือกผู้ส่ง",
"ai.staging.receiver": "ผู้รับ",
"ai.staging.selectReceiver": "เลือกผู้รับ",
"ai.staging.issuedDate": "วันที่ออก",
"ai.staging.receivedDate": "วันที่รับ",
"ai.staging.body": "เนื้อหา",
"ai.staging.approve": "อนุมัติ",
"ai.staging.approveSuccess": "อนุมัติรายการเรียบร้อยแล้ว",
"ai.staging.approveError": "ไม่สามารถอนุมัติรายการได้"
}