Initial reader-api backend extracted from reader
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user