haixunMaster/lib/ai/coerce-matrix.ts

118 lines
4.3 KiB
TypeScript
Raw Normal View History

2026-06-21 12:50:31 +00:00
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 "生成內容矩陣失敗";
}