ai.ts 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. import "server-only";
  2. import { db } from "@/lib/db";
  3. import { AI_KEYS } from "@/features/ai/Schema/aiSettingsSchema";
  4. import { localeNames, type Locale } from "@/i18n/config";
  5. import OpenAI from "openai";
  6. interface AiConfig {
  7. provider: string;
  8. apiKey: string;
  9. model: string;
  10. }
  11. export async function getAiConfig(organizationId: string): Promise<AiConfig> {
  12. const settings = await db.appSetting.findMany({
  13. where: {
  14. organizationId,
  15. key: {
  16. in: [
  17. AI_KEYS.AI_ENABLED,
  18. AI_KEYS.AI_PROVIDER,
  19. AI_KEYS.AI_API_KEY,
  20. AI_KEYS.AI_MODEL,
  21. ],
  22. },
  23. },
  24. });
  25. const map = new Map(settings.map((s) => [s.key, s.value]));
  26. if (map.get(AI_KEYS.AI_ENABLED) !== "true") {
  27. throw new Error("AI is not enabled. Configure it in Settings → AI.");
  28. }
  29. const provider = map.get(AI_KEYS.AI_PROVIDER);
  30. const apiKey = map.get(AI_KEYS.AI_API_KEY);
  31. const model = map.get(AI_KEYS.AI_MODEL);
  32. if (!provider || !apiKey || !model) {
  33. throw new Error(
  34. "AI is not fully configured. Set provider, API key, and model in Settings → AI.",
  35. );
  36. }
  37. return { provider, apiKey, model };
  38. }
  39. export function createClient(config: AiConfig): OpenAI {
  40. if (config.provider === "anthropic") {
  41. return new OpenAI({
  42. apiKey: config.apiKey,
  43. baseURL: "https://api.anthropic.com/v1/",
  44. defaultHeaders: {
  45. "anthropic-version": "2023-06-01",
  46. },
  47. });
  48. }
  49. return new OpenAI({ apiKey: config.apiKey });
  50. }
  51. function languageInstruction(locale: Locale): string {
  52. if (locale === "en") return "";
  53. const name = localeNames[locale] || locale;
  54. return `\n\nIMPORTANT: You MUST respond entirely in ${name}.`;
  55. }
  56. async function chatCompletion(
  57. organizationId: string,
  58. systemPrompt: string,
  59. userPrompt: string,
  60. ): Promise<string> {
  61. const config = await getAiConfig(organizationId);
  62. const client = createClient(config);
  63. const response = await client.chat.completions.create({
  64. model: config.model,
  65. messages: [
  66. { role: "system", content: systemPrompt },
  67. { role: "user", content: userPrompt },
  68. ],
  69. temperature: 0.7,
  70. max_tokens: 2000,
  71. });
  72. return response.choices[0]?.message?.content ?? "";
  73. }
  74. export async function visionCompletion(
  75. organizationId: string,
  76. systemPrompt: string,
  77. userText: string,
  78. imageUrls: string | string[],
  79. ): Promise<string> {
  80. const config = await getAiConfig(organizationId);
  81. const client = createClient(config);
  82. const urls = Array.isArray(imageUrls) ? imageUrls : [imageUrls];
  83. const content: Array<{ type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }> = [
  84. { type: "text", text: userText },
  85. ...urls.map((url) => ({ type: "image_url" as const, image_url: { url } })),
  86. ];
  87. const response = await client.chat.completions.create({
  88. model: config.model,
  89. messages: [
  90. { role: "system", content: systemPrompt },
  91. { role: "user", content },
  92. ],
  93. temperature: 0.3,
  94. max_tokens: 1000,
  95. });
  96. return response.choices[0]?.message?.content ?? "";
  97. }
  98. // ─── AI Feature Functions ────────────────────────────────────────────────────
  99. export interface ServiceContext {
  100. vehicleMake: string;
  101. vehicleModel: string;
  102. vehicleYear: number;
  103. licensePlate?: string | null;
  104. serviceType: string;
  105. serviceTitle: string;
  106. parts: { name: string; quantity: number }[];
  107. labor: { description: string; hours: number }[];
  108. }
  109. export async function generateServiceDescription(
  110. organizationId: string,
  111. context: ServiceContext,
  112. locale: Locale = "en",
  113. ): Promise<string> {
  114. const systemPrompt = `You are a professional automotive service writer. Generate a clear, professional service description for a customer-facing invoice. Be concise but thorough. Do not include pricing. Write in plain text, no markdown.${languageInstruction(locale)}`;
  115. const partsStr =
  116. context.parts.length > 0
  117. ? context.parts.map((p) => `- ${p.name} (qty: ${p.quantity})`).join("\n")
  118. : "No parts listed";
  119. const laborStr =
  120. context.labor.length > 0
  121. ? context.labor
  122. .map((l) => `- ${l.description} (${l.hours}h)`)
  123. .join("\n")
  124. : "No labor listed";
  125. const userPrompt = `Vehicle: ${context.vehicleYear} ${context.vehicleMake} ${context.vehicleModel}${context.licensePlate ? ` (${context.licensePlate})` : ""}
  126. Service type: ${context.serviceType}
  127. Title: ${context.serviceTitle}
  128. Parts used:
  129. ${partsStr}
  130. Labor performed:
  131. ${laborStr}
  132. Write a professional service description and diagnostic notes for the invoice.`;
  133. return chatCompletion(organizationId, systemPrompt, userPrompt);
  134. }
  135. export interface ServiceHistoryRecord {
  136. title: string;
  137. description: string | null;
  138. serviceDate: Date | null;
  139. startDateTime?: Date | null;
  140. type: string;
  141. cost: number;
  142. mileage: number | null;
  143. }
  144. export async function summarizeServiceHistory(
  145. organizationId: string,
  146. vehicle: { make: string; model: string; year: number; licensePlate?: string | null },
  147. records: ServiceHistoryRecord[],
  148. locale: Locale = "en",
  149. ): Promise<string> {
  150. const systemPrompt = `You are an automotive service advisor. Summarize a vehicle's complete service history as structured JSON.
  151. Return ONLY valid JSON, no markdown, no explanation. Use this exact schema:
  152. {"overview":"1-2 sentence general condition summary","majorWork":[{"title":"Work title","date":"YYYY-MM-DD or null","cost":0}],"recurringIssues":[{"title":"Issue name","description":"Brief explanation of the pattern"}],"upcomingMaintenance":[{"item":"Maintenance item","urgency":"high|medium|low","reason":"Why it's needed"}]}
  153. majorWork: list the 3-5 most significant services performed (most recent first).
  154. recurringIssues: list any patterns or repeated problems with title and description (empty array if none).
  155. upcomingMaintenance: predict 2-4 likely upcoming needs based on mileage, age, and service history.${languageInstruction(locale)}`;
  156. const recordsStr = records
  157. .map(
  158. (r) =>
  159. `- ${r.serviceDate ? new Date(r.startDateTime ?? r.serviceDate).toISOString().slice(0, 10) : "Unknown date"}: ${r.title} (${r.type}) — Cost: ${r.cost}${r.mileage ? `, Mileage: ${r.mileage}` : ""}${r.description ? `\n Notes: ${r.description}` : ""}`,
  160. )
  161. .join("\n");
  162. const userPrompt = `Vehicle: ${vehicle.year} ${vehicle.make} ${vehicle.model}${vehicle.licensePlate ? ` (${vehicle.licensePlate})` : ""}
  163. Service history (${records.length} records):
  164. ${recordsStr || "No service records found."}
  165. Return JSON only.`;
  166. return chatCompletion(organizationId, systemPrompt, userPrompt);
  167. }
  168. export async function getCommonIssues(
  169. organizationId: string,
  170. vehicle: { make: string; model: string; year: number },
  171. locale: Locale = "en",
  172. ): Promise<string> {
  173. const systemPrompt = `You are an expert automotive technician. Return a JSON array of exactly 5 critical known issues for the requested vehicle, sorted by severity and cost (highest first). Only serious, well-documented problems.
  174. Return ONLY valid JSON, no markdown, no explanation. Use this exact schema:
  175. [{"title":"Issue name","description":"1-2 sentence explanation","cost":"X,XXX–X,XXX","risk":"safety|engine|transmission|electrical|other","severity":5}]
  176. severity is 1-5 based on how widely reported the issue is (5 = extremely common/well-documented, affects majority of vehicles; 1 = rare but critical).${languageInstruction(locale)}`;
  177. const userPrompt = `What are the 5 most critical and common issues with the ${vehicle.year} ${vehicle.make} ${vehicle.model}? Return JSON only.`;
  178. return chatCompletion(organizationId, systemPrompt, userPrompt);
  179. }
  180. export async function testAiConnection(
  181. organizationId: string,
  182. ): Promise<boolean> {
  183. const config = await getAiConfig(organizationId);
  184. const client = createClient(config);
  185. const response = await client.chat.completions.create({
  186. model: config.model,
  187. messages: [{ role: "user", content: "Say OK" }],
  188. max_tokens: 5,
  189. });
  190. return !!response.choices[0]?.message?.content;
  191. }