Critical fix for Docker deployment where AI features were trying to connect to localhost:11434 instead of using configured provider (Ollama Docker service or OpenAI). Problems fixed: 1. Reformulation (clarify/shorten/improve) failing with ECONNREFUSED 127.0.0.1:11434 2. Auto-labels failing with same error 3. Notebook summaries failing 4. Could not switch from Ollama to OpenAI in admin Root cause: - Code had hardcoded fallback to 'http://localhost:11434' in multiple places - .env.docker file not created on server (gitignore'd) - No validation that required environment variables were set Changes: 1. lib/ai/factory.ts: - Remove hardcoded 'http://localhost:11434' fallback - Only use localhost for local development (NODE_ENV !== 'production') - Throw error if OLLAMA_BASE_URL not set in production 2. lib/ai/providers/ollama.ts: - Remove default parameter 'http://localhost:11434' from constructor - Require baseUrl to be explicitly passed - Throw error if baseUrl is missing 3. lib/ai/services/paragraph-refactor.service.ts: - Remove 'http://localhost:11434' fallback (2 locations) - Require OLLAMA_BASE_URL to be set - Throw clear error if not configured 4. app/(main)/admin/settings/admin-settings-form.tsx: - Add debug info showing current provider state - Display database config value for transparency - Help troubleshoot provider selection issues 5. DOCKER-SETUP.md: - Complete guide for Docker configuration - Instructions for .env.docker setup - Examples for Ollama Docker, OpenAI, and external Ollama - Troubleshooting common issues Usage: On server, create .env.docker with proper provider configuration: - Ollama in Docker: OLLAMA_BASE_URL="http://ollama:11434" - OpenAI: OPENAI_API_KEY="sk-..." - External Ollama: OLLAMA_BASE_URL="http://SERVER_IP:11434" Then in admin interface, users can independently configure: - Tags Provider (for auto-labels, AI features) - Embeddings Provider (for semantic search) Result: ✓ Clear errors if Ollama not configured ✓ Can switch to OpenAI freely in admin ✓ No more hardcoded localhost in production ✓ Proper separation between local dev and Docker production Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
358 lines
11 KiB
TypeScript
358 lines
11 KiB
TypeScript
/**
|
|
* Paragraph Refactor Service
|
|
* Provides AI-powered text reformulation with 3 options:
|
|
* 1. Clarify - Make ambiguous text clearer
|
|
* 2. Shorten - Condense while keeping meaning
|
|
* 3. Improve Style - Enhance readability and flow
|
|
*/
|
|
|
|
import { LanguageDetectionService } from './language-detection.service'
|
|
|
|
export type RefactorMode = 'clarify' | 'shorten' | 'improveStyle'
|
|
|
|
export interface RefactorOption {
|
|
mode: RefactorMode
|
|
label: string
|
|
description: string
|
|
icon: string
|
|
}
|
|
|
|
export interface RefactorResult {
|
|
original: string
|
|
refactored: string
|
|
mode: RefactorMode
|
|
language: string
|
|
wordCountChange: {
|
|
original: number
|
|
refactored: number
|
|
difference: number
|
|
percentage: number
|
|
}
|
|
}
|
|
|
|
export const REFACTOR_OPTIONS: RefactorOption[] = [
|
|
{
|
|
mode: 'clarify',
|
|
label: 'Clarify',
|
|
description: 'Make the text clearer and easier to understand',
|
|
icon: '💡'
|
|
},
|
|
{
|
|
mode: 'shorten',
|
|
label: 'Shorten',
|
|
description: 'Condense the text while keeping key information',
|
|
icon: '✂️'
|
|
},
|
|
{
|
|
mode: 'improveStyle',
|
|
label: 'Improve Style',
|
|
description: 'Enhance readability, flow, and expression',
|
|
icon: '✨'
|
|
}
|
|
]
|
|
|
|
export class ParagraphRefactorService {
|
|
private languageDetection: LanguageDetectionService
|
|
private readonly MIN_WORDS = 10
|
|
private readonly MAX_WORDS = 500
|
|
|
|
constructor() {
|
|
this.languageDetection = new LanguageDetectionService()
|
|
}
|
|
|
|
/**
|
|
* Refactor a paragraph with the specified mode
|
|
*/
|
|
async refactor(
|
|
content: string,
|
|
mode: RefactorMode
|
|
): Promise<RefactorResult> {
|
|
// Validate word count
|
|
const wordCount = content.split(/\s+/).length
|
|
if (wordCount < this.MIN_WORDS || wordCount > this.MAX_WORDS) {
|
|
throw new Error(
|
|
`Please select ${this.MIN_WORDS}-${this.MAX_WORDS} words to reformulate`
|
|
)
|
|
}
|
|
|
|
// Detect language
|
|
const { language } = await this.languageDetection.detectLanguage(content)
|
|
|
|
try {
|
|
// Build prompts
|
|
const systemPrompt = this.getSystemPrompt(mode)
|
|
const userPrompt = this.getUserPrompt(mode, content, language)
|
|
|
|
// Get AI provider response using fetch
|
|
let baseUrl = process.env.OLLAMA_BASE_URL
|
|
|
|
if (!baseUrl) {
|
|
throw new Error('OLLAMA_BASE_URL environment variable is required')
|
|
}
|
|
|
|
// Remove /api suffix if present to avoid double /api/api/...
|
|
if (baseUrl.endsWith('/api')) {
|
|
baseUrl = baseUrl.slice(0, -4)
|
|
}
|
|
const modelName = process.env.OLLAMA_MODEL || 'granite4:latest'
|
|
|
|
const response = await fetch(`${baseUrl}/api/generate`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
model: modelName,
|
|
system: systemPrompt,
|
|
prompt: userPrompt,
|
|
stream: false,
|
|
}),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Provider error: ${response.statusText}`)
|
|
}
|
|
|
|
const data = await response.json()
|
|
|
|
const refactored = this.extractRefactoredText(data.response)
|
|
|
|
// Calculate word count change
|
|
const refactoredWordCount = refactored.split(/\s+/).length
|
|
const wordCountChange = {
|
|
original: wordCount,
|
|
refactored: refactoredWordCount,
|
|
difference: refactoredWordCount - wordCount,
|
|
percentage: ((refactoredWordCount - wordCount) / wordCount) * 100
|
|
}
|
|
|
|
return {
|
|
original: content,
|
|
refactored,
|
|
mode,
|
|
language,
|
|
wordCountChange
|
|
}
|
|
} catch (error) {
|
|
throw new Error('Failed to refactor paragraph. Please try again.')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all 3 refactor options for a paragraph at once
|
|
* More efficient than calling refactor() 3 times
|
|
*/
|
|
async refactorAllModes(content: string): Promise<RefactorResult[]> {
|
|
// Validate word count
|
|
const wordCount = content.split(/\s+/).length
|
|
if (wordCount < this.MIN_WORDS || wordCount > this.MAX_WORDS) {
|
|
throw new Error(
|
|
`Please select ${this.MIN_WORDS}-${this.MAX_WORDS} words to reformulate`
|
|
)
|
|
}
|
|
|
|
// Detect language
|
|
const { language } = await this.languageDetection.detectLanguage(content)
|
|
|
|
try {
|
|
// System prompt for all modes
|
|
const systemPrompt = `You are an expert text editor who can improve text in multiple ways.
|
|
Your task is to provide 3 different reformulations of the user's text.
|
|
|
|
For each reformulation:
|
|
1. Clarify: Make the text clearer, more explicit, easier to understand
|
|
2. Shorten: Condense the text while preserving all key information and meaning
|
|
3. Improve Style: Enhance readability, flow, vocabulary, and expression
|
|
|
|
CRITICAL LANGUAGE RULE: You MUST respond in the EXACT SAME LANGUAGE as the input text.
|
|
- If input is French, ALL 3 outputs MUST be in French
|
|
- If input is German, ALL 3 outputs MUST be in German
|
|
- If input is Spanish, ALL 3 outputs MUST be in Spanish
|
|
- NEVER translate to English unless the input is in English
|
|
|
|
Maintain the original meaning and intent:
|
|
- For "shorten", aim to reduce by 30-50% while keeping all key points
|
|
- For "clarify", expand where necessary but keep it natural
|
|
- For "improve style", keep similar length but enhance quality
|
|
|
|
Output Format (JSON):
|
|
{
|
|
"clarify": "clarified text here...",
|
|
"shorten": "shortened text here...",
|
|
"improveStyle": "improved text here..."
|
|
}`
|
|
|
|
const userPrompt = `CRITICAL LANGUAGE INSTRUCTION: The text below is in ${language}. Your response MUST be in ${language}. Do NOT translate to English.
|
|
|
|
Please provide 3 reformulations of this ${language} text:
|
|
|
|
${content}
|
|
|
|
Original language: ${language}
|
|
IMPORTANT: Provide all 3 versions in ${language}. No English, no explanations.`
|
|
|
|
// Get AI provider response using fetch
|
|
let baseUrl = process.env.OLLAMA_BASE_URL
|
|
|
|
if (!baseUrl) {
|
|
throw new Error('OLLAMA_BASE_URL environment variable is required')
|
|
}
|
|
|
|
// Remove /api suffix if present to avoid double /api/api/...
|
|
if (baseUrl.endsWith('/api')) {
|
|
baseUrl = baseUrl.slice(0, -4)
|
|
}
|
|
const modelName = process.env.OLLAMA_MODEL || 'granite4:latest'
|
|
|
|
const response = await fetch(`${baseUrl}/api/generate`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
model: modelName,
|
|
system: systemPrompt,
|
|
prompt: userPrompt,
|
|
stream: false,
|
|
}),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Provider error: ${response.statusText}`)
|
|
}
|
|
|
|
const data = await response.json()
|
|
|
|
// Parse JSON response
|
|
const jsonResponse = JSON.parse(data.response)
|
|
|
|
const modes: RefactorMode[] = ['clarify', 'shorten', 'improveStyle']
|
|
const results: RefactorResult[] = []
|
|
|
|
for (const mode of modes) {
|
|
if (!jsonResponse[mode]) continue
|
|
|
|
const refactored = this.extractRefactoredText(jsonResponse[mode])
|
|
const refactoredWordCount = refactored.split(/\s+/).length
|
|
|
|
results.push({
|
|
original: content,
|
|
refactored,
|
|
mode,
|
|
language,
|
|
wordCountChange: {
|
|
original: wordCount,
|
|
refactored: refactoredWordCount,
|
|
difference: refactoredWordCount - wordCount,
|
|
percentage: ((refactoredWordCount - wordCount) / wordCount) * 100
|
|
}
|
|
})
|
|
}
|
|
|
|
return results
|
|
} catch (error) {
|
|
throw new Error('Failed to generate refactor options. Please try again.')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get mode-specific system prompt
|
|
*/
|
|
private getSystemPrompt(mode: RefactorMode): string {
|
|
const prompts = {
|
|
clarify: `You are an expert at making text clearer and more understandable.
|
|
Your goal: Rewrite the text to eliminate ambiguity, add necessary context, and improve clarity.
|
|
|
|
CRITICAL LANGUAGE RULE: You MUST respond in the EXACT SAME LANGUAGE as the input text. If input is French, output MUST be French. If input is German, output MUST be German. NEVER translate to English.
|
|
Maintain the original meaning and tone, just make it clearer.`,
|
|
|
|
shorten: `You are an expert at concise writing.
|
|
Your goal: Reduce the text length by 30-50% while preserving ALL key information and meaning.
|
|
|
|
CRITICAL LANGUAGE RULE: You MUST respond in the EXACT SAME LANGUAGE as the input text. If input is French, output MUST be French. If input is German, output MUST be German. NEVER translate to English.
|
|
Remove fluff, repetition, and unnecessary words, but keep the substance.`,
|
|
|
|
improveStyle: `You are an expert editor with a focus on readability and flow.
|
|
Your goal: Enhance the text's style, vocabulary, sentence structure, and overall quality.
|
|
|
|
CRITICAL LANGUAGE RULE: You MUST respond in the EXACT SAME LANGUAGE as the input text. If input is French, output MUST be French. If input is German, output MUST be German. NEVER translate to English.
|
|
Maintain similar length but make it sound more professional and polished.`
|
|
}
|
|
|
|
return prompts[mode]
|
|
}
|
|
|
|
/**
|
|
* Get mode-specific user prompt
|
|
*/
|
|
private getUserPrompt(mode: RefactorMode, content: string, language: string): string {
|
|
const instructions = {
|
|
clarify: `IMPORTANT: The text below is in ${language}. Your response MUST be in ${language}. Do NOT translate to English.
|
|
|
|
Please clarify and make this ${language} text easier to understand:`,
|
|
|
|
shorten: `IMPORTANT: The text below is in ${language}. Your response MUST be in ${language}. Do NOT translate to English.
|
|
|
|
Please shorten this ${language} text while keeping all key information:`,
|
|
|
|
improveStyle: `IMPORTANT: The text below is in ${language}. Your response MUST be in ${language}. Do NOT translate to English.
|
|
|
|
Please improve the style and readability of this ${language} text:`
|
|
}
|
|
|
|
return `${instructions[mode]}
|
|
|
|
${content}
|
|
|
|
CRITICAL: Respond ONLY with the refactored text in ${language}. No explanations, no meta-commentary, no English.`
|
|
}
|
|
|
|
/**
|
|
* Extract refactored text from AI response
|
|
* Handles JSON, markdown code blocks, or plain text
|
|
*/
|
|
private extractRefactoredText(response: string): string {
|
|
// Try JSON first
|
|
if (response.trim().startsWith('{')) {
|
|
try {
|
|
const parsed = JSON.parse(response)
|
|
// Look for common response fields
|
|
return parsed.refactored || parsed.text || parsed.result || response
|
|
} catch {
|
|
// Not valid JSON, continue
|
|
}
|
|
}
|
|
|
|
// Try markdown code block
|
|
const codeBlockMatch = response.match(/```(?:markdown)?\n([\s\S]+?)\n```/)
|
|
if (codeBlockMatch) {
|
|
return codeBlockMatch[1].trim()
|
|
}
|
|
|
|
// Fallback: trim whitespace and quotes
|
|
return response.trim().replace(/^["']|["']$/g, '')
|
|
}
|
|
|
|
/**
|
|
* Validate that text is within acceptable word count range
|
|
*/
|
|
validateWordCount(content: string): { valid: boolean; error?: string } {
|
|
const wordCount = content.split(/\s+/).length
|
|
|
|
if (wordCount < this.MIN_WORDS) {
|
|
return {
|
|
valid: false,
|
|
error: `Please select at least ${this.MIN_WORDS} words to reformulate (currently ${wordCount} words)`
|
|
}
|
|
}
|
|
|
|
if (wordCount > this.MAX_WORDS) {
|
|
return {
|
|
valid: false,
|
|
error: `Please select at most ${this.MAX_WORDS} words to reformulate (currently ${wordCount} words)`
|
|
}
|
|
}
|
|
|
|
return { valid: true }
|
|
}
|
|
}
|
|
|
|
// Singleton instance
|
|
export const paragraphRefactorService = new ParagraphRefactorService()
|