175 lines
6.0 KiB
TypeScript
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 })
|
|
}
|
|
}
|