Files
reader-api/app/api/mod/ai-tools/novel-enrich-batch/route.ts
T

175 lines
6.0 KiB
TypeScript

import { NextResponse } from "next/server"
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
type EnrichedItem = {
id: string
title: string
originalTitle: string
authorName: string
originalAuthorName: string
description: string
coverUrl?: string
status: "Đang ra" | "Hoàn thành" | "Tạm ngưng"
genresSuggested: string[]
firstPublishYear?: number
confidence: number
source: string
sourceUrl?: string
}
function normalizeText(value: string): string {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9\s]/g, " ")
.replace(/\s+/g, " ")
.trim()
}
function trimText(value: string, maxLength: number): string {
if (value.length <= maxLength) return value
return `${value.slice(0, maxLength - 1).trimEnd()}...`
}
function buildBatchPrompt(novels: any[]) {
const itemsText = novels.map(n => {
const shortDesc = trimText(n.description || "", 1500)
return `[ID: ${n.id}]\nTên: ${n.title}\nTên gốc: ${n.originalTitle || ""}\nTác giả: ${n.authorName}\nTác giả gốc: ${n.originalAuthorName || ""}\nThể loại: ${(n.genres || []).map((g: any) => g.genre.name).join(", ")}\nMô tả: ${shortDesc}`
}).join("\n\n---\n\n")
return [
"Bạn là biên tập viên truyện dịch tiếng Trung.",
"Nhiệm vụ: bổ sung thông tin bị thiếu (Tên gốc, Tác giả gốc, Mô tả, Thể loại) cho danh sách tác phẩm sau bằng cách tìm kiếm trên Qidian, JJWXC, v.v...",
"Trường 'description': Nếu mô tả gốc đã chi tiết, chỉ cần sửa chính tả. Nếu trống/ngắn, hãy viết mới 1 đoạn giới thiệu dài chi tiết bám sát nội dung gốc (KHÔNG tóm tắt kiểu chung chung).",
"Trạng thái (status) phải luôn là: Đang ra, Hoàn thành, hoặc Tạm ngưng.",
"Kết quả BẮT BUỘC là 1 JSON Object chứa mảng 'results'. Mỗi item trong mảng phải có key 'id' khớp với báo cáo.",
`Schema: {"results":[{"id":"","title":"","originalTitle":"","authorName":"","originalAuthorName":"","description":"","coverUrl":"","status":"Đang ra|Hoàn thành|Tạm ngưng","genresSuggested":[],"firstPublishYear":2020,"confidence":80,"source":"","sourceUrl":""}]}`,
"Dưới đây là danh sách truyện cần xử lý:\n",
itemsText
].join("\n")
}
function extractJsonCandidate(text: string): string {
const trimmed = text.trim()
if (!trimmed) return ""
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i)
if (fenced?.[1]) return fenced[1].trim()
const firstBrace = trimmed.indexOf("{")
const lastBrace = trimmed.lastIndexOf("}")
if (firstBrace >= 0 && lastBrace > firstBrace) {
return trimmed.slice(firstBrace, lastBrace + 1)
}
return trimmed
}
export async function POST(req: Request) {
const session = await getServerSession(authOptions)
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
return NextResponse.json({ error: "Không có quyền truy cập" }, { status: 401 })
}
const { novelIds } = await req.json()
if (!Array.isArray(novelIds) || novelIds.length === 0 || novelIds.length > 20) {
return NextResponse.json({ error: "novelIds không hợp lệ hoặc quá lớn (tối đa 20)" }, { status: 400 })
}
const accessWhere = session.user.role === "ADMIN"
? { id: { in: novelIds } }
: {
id: { in: novelIds },
OR: [
{ uploaderId: session.user.id },
{ uploaderId: null },
],
}
const novels = await prisma.novel.findMany({
where: accessWhere,
select: {
id: true,
title: true,
originalTitle: true,
authorName: true,
originalAuthorName: true,
description: true,
coverUrl: true,
status: true,
genres: { select: { genre: { select: { name: true } } } },
}
})
if (novels.length === 0) {
return NextResponse.json({ error: "Không tìm thấy truyện nào hợp lệ" }, { status: 404 })
}
const apiKey = process.env.DEEKSEEK_KEY?.trim() || process.env.DEEPSEEK_KEY?.trim()
const model = process.env.DEEPSEEK_MODEL?.trim() || "deepseek-chat"
if (!apiKey) {
return NextResponse.json({ error: "Thiếu DEEPSEEK_KEY" }, { status: 400 })
}
const prompt = buildBatchPrompt(novels)
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 90000) // 90 seconds for batch
const startedAt = Date.now()
try {
const res = await fetch("https://api.deepseek.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
temperature: 0.2,
max_tokens: 2500,
response_format: { type: "json_object" },
messages: [
{ role: "system", content: "You are a helpful assistant. You must output only valid standard JSON object following the prompt schema, without markdown formatting." },
{ role: "user", content: prompt },
],
}),
signal: controller.signal,
})
clearTimeout(timeout)
if (!res.ok) {
const errorText = await res.text().catch(() => "")
throw new Error(`HTTP ${res.status}: ${errorText.slice(0, 200)}`)
}
const data = await res.json()
const text = data.choices?.[0]?.message?.content?.trim() || ""
const jsonText = extractJsonCandidate(text)
let parsed: any = null
try {
parsed = JSON.parse(jsonText)
} catch {
throw new Error("Phản hồi JSON bị hỏng")
}
const results = Array.isArray(parsed?.results) ? parsed.results : []
return NextResponse.json({
success: true,
latencyMs: Date.now() - startedAt,
model,
count: results.length,
results,
sourceNovels: novels,
})
} catch (error: any) {
clearTimeout(timeout)
return NextResponse.json({ error: `DeepSeek Error: ${error.message}` }, { status: 500 })
}
}