haixunMaster/lib/ai/coerce-matrix.ts

118 lines
4.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { z } from "zod";
import { THREADS_MAX_CHARS } from "@/lib/utils";
import type { MatrixRow } from "@/lib/types/research";
import { explainProviderApiError } from "./opencode-go-settings";
export const matrixLooseSchema = z.object({
rows: z
.array(
z.object({
sortOrder: z.union([z.number(), z.string()]).optional(),
searchTag: z.string().optional(),
angle: z.string().optional(),
hook: z.string().optional(),
text: z.string().optional(),
referenceNotes: z.string().optional(),
sourcePermalinks: z.union([z.array(z.string()), z.string()]).optional(),
rationale: z.string().optional(),
})
)
.min(1),
});
function asString(value: unknown): string {
if (typeof value === "string") return value.trim();
if (typeof value === "number" || typeof value === "boolean") return String(value);
return "";
}
function asStringArray(value: unknown): string[] {
if (Array.isArray(value)) {
return value.map((item) => asString(item)).filter(Boolean);
}
const single = asString(value);
return single ? [single] : [];
}
function asSortOrder(value: unknown, fallback: number): number {
if (typeof value === "number" && Number.isFinite(value)) return Math.max(1, Math.floor(value));
const parsed = parseInt(asString(value), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
export function coerceMatrixRaw(
parsed: unknown,
fallback: { query: string; count: number }
): MatrixRow[] {
const root =
parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
const rowsRaw =
root.rows ??
root.matrix ??
root.content ??
(Array.isArray(parsed) ? parsed : null);
if (!Array.isArray(rowsRaw) || rowsRaw.length === 0) {
throw new Error("AI 回傳的內容矩陣缺少 rows 陣列");
}
const rows: MatrixRow[] = [];
for (let i = 0; i < rowsRaw.length; i++) {
const item = rowsRaw[i];
if (!item || typeof item !== "object") continue;
const obj = item as Record<string, unknown>;
const text = asString(obj.text ?? obj.body ?? obj.post ?? obj.content);
if (!text) continue;
rows.push({
sortOrder: asSortOrder(obj.sortOrder ?? obj.order ?? obj.index, i + 1),
searchTag: asString(obj.searchTag ?? obj.tag ?? obj.topic) || fallback.query,
angle: asString(obj.angle ?? obj.perspective) || "待補切角",
hook: asString(obj.hook ?? obj.opening) || text.split("\n")[0]?.slice(0, 80) || text.slice(0, 80),
text: text.slice(0, THREADS_MAX_CHARS),
referenceNotes:
asString(obj.referenceNotes ?? obj.references ?? obj.notes) || "(待補參考重點)",
sourcePermalinks: asStringArray(obj.sourcePermalinks ?? obj.sources ?? obj.permalinks),
rationale: asString(obj.rationale ?? obj.reason) || "依優質素材衍生的企劃切角",
});
}
if (rows.length === 0) {
throw new Error("AI 回傳的內容矩陣沒有可用貼文");
}
return rows
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((row, index) => ({ ...row, sortOrder: index + 1 }))
.slice(0, fallback.count);
}
export function formatMatrixError(error: unknown): string {
if (typeof error === "string") return error;
if (error instanceof z.ZodError) {
const detail = error.issues
.slice(0, 3)
.map((issue) => `${issue.path.join(".") || "回傳內容"}${issue.message}`)
.join("");
return `AI 回傳的內容矩陣格式不完整(${detail})。請再試一次。`;
}
if (error instanceof Error) {
const extra = error as Error & { responseBody?: string };
const body = typeof extra.responseBody === "string" ? extra.responseBody : "";
const explained = explainProviderApiError(error.message, body);
if (explained) return explained;
if (body.includes("hex escape") || body.includes("parse the request body")) {
return "參考素材含有特殊字元,導致 AI 請求格式錯誤。系統已自動清理字元,請再按一次「內容矩陣」。";
}
if (error.message === "Bad Request" && body) {
return `AI 請求失敗:${body.slice(0, 200)}`;
}
if (error.message === "Bad Request") {
return "AI 供應商拒絕請求Bad Request。請到設定頁確認 API key 或更換模型後重試。";
}
return error.message;
}
return "生成內容矩陣失敗";
}