Kaynağa Gözat

refactor(suggest-path): extract shared LLM call utility from analyze-content and evaluate-candidates

Both services had identical boilerplate for OpenAI client setup, chat completion,
stream check, JSON parsing, and validation. Extracted into callLlmForJson<T>()
to eliminate ~60 lines of duplication.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
VANELLOPE\tomoyuki-t 1 ay önce
ebeveyn
işleme
7f4f47300c

+ 8 - 52
apps/app/src/features/ai-tools/suggest-path/server/services/analyze-content.ts

@@ -1,15 +1,10 @@
-import type { OpenaiServiceType } from '~/features/openai/interfaces/ai';
 import { instructionsForInformationTypes } from '~/features/openai/server/services/assistant/instructions/commons';
-import {
-  getClient,
-  isStreamResponse,
-} from '~/features/openai/server/services/client-delegator';
-import { configManager } from '~/server/service/config-manager';
 
 import type {
   ContentAnalysis,
   InformationType,
 } from '../../interfaces/suggest-path-types';
+import { callLlmForJson } from './call-llm-for-json';
 
 const VALID_INFORMATION_TYPES: readonly InformationType[] = ['flow', 'stock'];
 
@@ -46,50 +41,11 @@ const isValidContentAnalysis = (parsed: unknown): parsed is ContentAnalysis => {
   return true;
 };
 
-export const analyzeContent = async (
-  body: string,
-): Promise<ContentAnalysis> => {
-  const openaiServiceType = configManager.getConfig(
-    'openai:serviceType',
-  ) as OpenaiServiceType;
-  const client = getClient({ openaiServiceType });
-
-  const completion = await client.chatCompletion({
-    model: 'gpt-4.1-nano',
-    messages: [
-      { role: 'system', content: SYSTEM_PROMPT },
-      { role: 'user', content: body },
-    ],
-  });
-
-  if (isStreamResponse(completion)) {
-    throw new Error('Unexpected streaming response from chatCompletion');
-  }
-
-  const choice = completion.choices[0];
-  if (choice == null) {
-    throw new Error('No choices returned from chatCompletion');
-  }
-
-  const content = choice.message.content;
-  if (content == null) {
-    throw new Error('No content returned from chatCompletion');
-  }
-
-  let parsed: unknown;
-  try {
-    parsed = JSON.parse(content);
-  } catch {
-    throw new Error(
-      `Failed to parse LLM response as JSON: ${content.slice(0, 200)}`,
-    );
-  }
-
-  if (!isValidContentAnalysis(parsed)) {
-    throw new Error(
-      'Invalid content analysis response: expected { keywords: string[], informationType: "flow" | "stock" }',
-    );
-  }
-
-  return parsed;
+export const analyzeContent = (body: string): Promise<ContentAnalysis> => {
+  return callLlmForJson(
+    SYSTEM_PROMPT,
+    body,
+    isValidContentAnalysis,
+    'Invalid content analysis response: expected { keywords: string[], informationType: "flow" | "stock" }',
+  );
 };

+ 55 - 0
apps/app/src/features/ai-tools/suggest-path/server/services/call-llm-for-json.ts

@@ -0,0 +1,55 @@
+import type { OpenaiServiceType } from '~/features/openai/interfaces/ai';
+import {
+  getClient,
+  isStreamResponse,
+} from '~/features/openai/server/services/client-delegator';
+import { configManager } from '~/server/service/config-manager';
+
+export const callLlmForJson = async <T>(
+  systemPrompt: string,
+  userMessage: string,
+  validate: (parsed: unknown) => parsed is T,
+  validationErrorMessage: string,
+): Promise<T> => {
+  const openaiServiceType = configManager.getConfig(
+    'openai:serviceType',
+  ) as OpenaiServiceType;
+  const client = getClient({ openaiServiceType });
+
+  const completion = await client.chatCompletion({
+    model: 'gpt-4.1-nano',
+    messages: [
+      { role: 'system', content: systemPrompt },
+      { role: 'user', content: userMessage },
+    ],
+  });
+
+  if (isStreamResponse(completion)) {
+    throw new Error('Unexpected streaming response from chatCompletion');
+  }
+
+  const choice = completion.choices[0];
+  if (choice == null) {
+    throw new Error('No choices returned from chatCompletion');
+  }
+
+  const content = choice.message.content;
+  if (content == null) {
+    throw new Error('No content returned from chatCompletion');
+  }
+
+  let parsed: unknown;
+  try {
+    parsed = JSON.parse(content);
+  } catch {
+    throw new Error(
+      `Failed to parse LLM response as JSON: ${content.slice(0, 200)}`,
+    );
+  }
+
+  if (!validate(parsed)) {
+    throw new Error(validationErrorMessage);
+  }
+
+  return parsed;
+};

+ 17 - 59
apps/app/src/features/ai-tools/suggest-path/server/services/evaluate-candidates.ts

@@ -1,16 +1,11 @@
-import type { OpenaiServiceType } from '~/features/openai/interfaces/ai';
 import { instructionsForInformationTypes } from '~/features/openai/server/services/assistant/instructions/commons';
-import {
-  getClient,
-  isStreamResponse,
-} from '~/features/openai/server/services/client-delegator';
-import { configManager } from '~/server/service/config-manager';
 
 import type {
   ContentAnalysis,
   EvaluatedSuggestion,
   SearchCandidate,
 } from '../../interfaces/suggest-path-types';
+import { callLlmForJson } from './call-llm-for-json';
 
 const SYSTEM_PROMPT = [
   'You are a page save location evaluator for a wiki system. ',
@@ -96,62 +91,25 @@ const isValidEvaluatedSuggestion = (
   return true;
 };
 
-export const evaluateCandidates = async (
+const isValidEvaluatedSuggestionArray = (
+  parsed: unknown,
+): parsed is EvaluatedSuggestion[] => {
+  if (!Array.isArray(parsed)) {
+    return false;
+  }
+  return parsed.every(isValidEvaluatedSuggestion);
+};
+
+export const evaluateCandidates = (
   body: string,
   analysis: ContentAnalysis,
   candidates: SearchCandidate[],
 ): Promise<EvaluatedSuggestion[]> => {
-  const openaiServiceType = configManager.getConfig(
-    'openai:serviceType',
-  ) as OpenaiServiceType;
-  const client = getClient({ openaiServiceType });
-
   const userMessage = buildUserMessage(body, analysis, candidates);
-
-  const completion = await client.chatCompletion({
-    model: 'gpt-4.1-nano',
-    messages: [
-      { role: 'system', content: SYSTEM_PROMPT },
-      { role: 'user', content: userMessage },
-    ],
-  });
-
-  if (isStreamResponse(completion)) {
-    throw new Error('Unexpected streaming response from chatCompletion');
-  }
-
-  const choice = completion.choices[0];
-  if (choice == null) {
-    throw new Error('No choices returned from chatCompletion');
-  }
-
-  const content = choice.message.content;
-  if (content == null) {
-    throw new Error('No content returned from chatCompletion');
-  }
-
-  let parsed: unknown;
-  try {
-    parsed = JSON.parse(content);
-  } catch {
-    throw new Error(
-      `Failed to parse LLM response as JSON: ${content.slice(0, 200)}`,
-    );
-  }
-
-  if (!Array.isArray(parsed)) {
-    throw new Error(
-      'Invalid candidate evaluation response: expected JSON array',
-    );
-  }
-
-  for (const item of parsed) {
-    if (!isValidEvaluatedSuggestion(item)) {
-      throw new Error(
-        'Invalid suggestion in evaluation response: each item must have path (ending with /), label, and description',
-      );
-    }
-  }
-
-  return parsed as EvaluatedSuggestion[];
+  return callLlmForJson(
+    SYSTEM_PROMPT,
+    userMessage,
+    isValidEvaluatedSuggestionArray,
+    'Invalid candidate evaluation response: each item must have path (ending with /), label, and description',
+  );
 };