technical-implementation-details.md 16 KB

技術実装詳細

🏗️ アーキテクチャ変更

現在のフロー

graph TD
    A[User Input] --> B[useEditorAssistant.postMessage]
    B --> C[Server: edit/index.ts]
    C --> D[OpenAI Stream]
    D --> E[LlmResponseStreamProcessor]
    E --> F[jsonrepair + parse]
    F --> G[SseDetectedDiff]
    G --> H[useEditorAssistant.processMessage]
    H --> I[setDetectedDiff]
    I --> J[yText更新]

改修後のフロー

graph TD
    A[User Input] --> B[useEditorAssistant.postMessage]
    B --> C[Server: edit/index.ts]
    C --> D[OpenAI Stream with Search/Replace]
    D --> E[LlmResponseStreamProcessor]
    E --> F[jsonrepair + parse Search/Replace blocks]
    F --> G[MultiSearchReplaceProcessor]
    G --> H[Fuzzy Matching + Apply Diffs]
    H --> I[DiffApplicationResult]
    I --> J[SseDetectedDiff with Results]
    J --> K[useEditorAssistant.processMessage]
    K --> L[Enhanced Error Handling]
    L --> M[yText更新 with Validation]

📦 ファイル構成

新規作成ファイル

apps/app/src/features/openai/server/services/editor-assistant/
├── multi-search-replace-processor.ts          # メイン処理エンジン
├── fuzzy-matching.ts                          # 類似度計算ユーティリティ
├── diff-application-engine.ts                 # 差分適用ロジック
└── error-handlers.ts                          # エラーハンドリング

更新対象ファイル

apps/app/src/features/openai/
├── interfaces/editor-assistant/
│   ├── llm-response-schemas.ts                # Diffスキーマ更新
│   └── sse-schemas.ts                         # SSEスキーマ更新
├── server/
│   ├── routes/edit/index.ts                   # プロンプト・処理統合
│   └── services/editor-assistant/
│       └── llm-response-stream-processor.ts   # Search/Replace対応
└── client/services/
    └── editor-assistant.tsx                   # クライアント対応

🔍 核心技術実装

1. MultiSearchReplaceProcessor

export class MultiSearchReplaceProcessor {
  private fuzzyThreshold: number = 0.8;
  private bufferLines: number = 40;

  constructor(config?: ProcessorConfig) {
    this.fuzzyThreshold = config?.fuzzyThreshold ?? 0.8;
    this.bufferLines = config?.bufferLines ?? 40;
  }

  async applyDiffs(
    originalContent: string,
    diffs: LlmEditorAssistantDiff[]
  ): Promise<DiffApplicationResult> {
    // 行終端の検出
    const lineEnding = originalContent.includes('\r\n') ? '\r\n' : '\n';
    let resultLines = originalContent.split(/\r?\n/);
    let delta = 0;
    let appliedCount = 0;
    const failedParts: DiffError[] = [];

    // startLineでソート
    const sortedDiffs = diffs
      .map((diff, index) => ({ ...diff, originalIndex: index }))
      .sort((a, b) => (a.startLine || 0) - (b.startLine || 0));

    for (const diff of sortedDiffs) {
      const result = await this.applySingleDiff(
        resultLines, 
        diff, 
        delta
      );
      
      if (result.success) {
        resultLines = result.updatedLines;
        delta += result.lineDelta;
        appliedCount++;
      } else {
        failedParts.push(result.error);
      }
    }

    return {
      success: appliedCount > 0,
      appliedCount,
      failedParts: failedParts.length > 0 ? failedParts : undefined,
      content: appliedCount > 0 ? resultLines.join(lineEnding) : undefined,
    };
  }

  private async applySingleDiff(
    lines: string[],
    diff: LlmEditorAssistantDiff,
    delta: number
  ): Promise<SingleDiffResult> {
    // バリデーション
    if (!diff.search.trim()) {
      return {
        success: false,
        error: {
          type: 'EMPTY_SEARCH',
          message: '検索内容が空です',
          details: { searchContent: diff.search, suggestions: [] }
        }
      };
    }

    // 検索実行
    const searchResult = this.findBestMatch(lines, diff.search, diff.startLine, delta);
    
    if (!searchResult.found) {
      return {
        success: false,
        error: this.createSearchError(diff.search, searchResult)
      };
    }

    // 置換実行
    return this.applyReplacement(lines, diff, searchResult);
  }
}

2. Fuzzy Matching実装

import { distance } from 'fastest-levenshtein';

export class FuzzyMatcher {
  private threshold: number;

  constructor(threshold: number = 0.8) {
    this.threshold = threshold;
  }

  calculateSimilarity(original: string, search: string): number {
    if (search === '') return 0;
    
    // 正規化(スマートクォート等の処理)
    const normalizedOriginal = this.normalizeString(original);
    const normalizedSearch = this.normalizeString(search);

    if (normalizedOriginal === normalizedSearch) return 1;

    // Levenshtein距離による類似度計算
    const dist = distance(normalizedOriginal, normalizedSearch);
    const maxLength = Math.max(normalizedOriginal.length, normalizedSearch.length);
    
    return 1 - (dist / maxLength);
  }

  findBestMatch(
    lines: string[],
    searchChunk: string,
    startIndex: number = 0,
    endIndex?: number
  ): MatchResult {
    const searchLines = searchChunk.split(/\r?\n/);
    const searchLength = searchLines.length;
    const actualEndIndex = endIndex ?? lines.length;

    let bestScore = 0;
    let bestMatchIndex = -1;
    let bestMatchContent = '';

    // Middle-out検索
    const midPoint = Math.floor((startIndex + actualEndIndex) / 2);
    let leftIndex = midPoint;
    let rightIndex = midPoint + 1;

    while (leftIndex >= startIndex || rightIndex <= actualEndIndex - searchLength) {
      // 左側検索
      if (leftIndex >= startIndex) {
        const chunk = lines.slice(leftIndex, leftIndex + searchLength).join('\n');
        const similarity = this.calculateSimilarity(chunk, searchChunk);
        
        if (similarity > bestScore) {
          bestScore = similarity;
          bestMatchIndex = leftIndex;
          bestMatchContent = chunk;
        }
        leftIndex--;
      }

      // 右側検索
      if (rightIndex <= actualEndIndex - searchLength) {
        const chunk = lines.slice(rightIndex, rightIndex + searchLength).join('\n');
        const similarity = this.calculateSimilarity(chunk, searchChunk);
        
        if (similarity > bestScore) {
          bestScore = similarity;
          bestMatchIndex = rightIndex;
          bestMatchContent = chunk;
        }
        rightIndex++;
      }
    }

    return {
      found: bestScore >= this.threshold,
      score: bestScore,
      index: bestMatchIndex,
      content: bestMatchContent,
      threshold: this.threshold
    };
  }

  private normalizeString(str: string): string {
    return str
      .replace(/[\u2018\u2019]/g, "'")  // スマートクォート
      .replace(/[\u201C\u201D]/g, '"')  // スマートダブルクォート
      .replace(/\u2013/g, '-')         // en dash
      .replace(/\u2014/g, '--')        // em dash
      .normalize('NFC');
  }
}

3. エラーハンドリング強化

export interface DiffError {
  type: 'SEARCH_NOT_FOUND' | 'SIMILARITY_TOO_LOW' | 'MULTIPLE_MATCHES' | 'EMPTY_SEARCH';
  message: string;
  details: {
    searchContent: string;
    bestMatch?: string;
    similarity?: number;
    suggestions: string[];
    lineRange?: string;
  };
}

export class ErrorHandler {
  static createSearchError(
    searchContent: string,
    matchResult: MatchResult,
    startLine?: number
  ): DiffError {
    const lineRange = startLine ? ` at line: ${startLine}` : '';
    const similarityPercent = Math.floor((matchResult.score || 0) * 100);
    const thresholdPercent = Math.floor(matchResult.threshold * 100);

    return {
      type: 'SIMILARITY_TOO_LOW',
      message: `No sufficiently similar match found${lineRange} (${similarityPercent}% similar, needs ${thresholdPercent}%)`,
      details: {
        searchContent,
        bestMatch: matchResult.content || '(no match)',
        similarity: matchResult.score,
        suggestions: [
          'Use the read_file tool to get the latest content',
          'Check for whitespace and indentation differences',
          'Verify the search content matches exactly',
          `Consider lowering similarity threshold (currently ${thresholdPercent}%)`
        ],
        lineRange: startLine ? `starting at line ${startLine}` : 'start to end'
      }
    };
  }
}

4. 文字正規化システム

roo-codeと同レベルの文字正規化機能を実装:

// apps/app/src/features/openai/server/services/editor-assistant/text-normalization.ts
export const NORMALIZATION_MAPS = {
  // スマートクォートの正規化
  SMART_QUOTES: {
    '\u201C': '"', // 左ダブルクォート
    '\u201D': '"', // 右ダブルクォート
    '\u2018': "'", // 左シングルクォート
    '\u2019': "'", // 右シングルクォート
  },
  // タイポグラフィ文字の正規化
  TYPOGRAPHIC: {
    '\u2026': '...', // 省略記号
    '\u2014': '-',   // emダッシュ
    '\u2013': '-',   // enダッシュ
    '\u00A0': ' ',   // ノンブレーキングスペース
  },
};

export function normalizeForFuzzyMatch(text: string): string {
  return text
    .replace(/[\u201C\u201D]/g, '"')
    .replace(/[\u2018\u2019]/g, "'")
    .replace(/\u2026/g, '...')
    .replace(/\u2014/g, '-')
    .replace(/\u2013/g, '-')
    .replace(/\u00A0/g, ' ')
    .normalize('NFC'); // Unicode正規化
}

5. 段階的バリデーションシステム

roo-codeのバリデーション戦略を採用:

// マーカーシーケンス検証 → 内容検証 → 適用処理
export class ValidationPipeline {
  static validateDiffContent(diffContent: string): ValidationResult {
    // 1. マーカーシーケンス検証
    const markerResult = this.validateMarkerSequencing(diffContent);
    if (!markerResult.success) return markerResult;
    
    // 2. 内容検証
    const contentResult = this.validateContent(diffContent);
    if (!contentResult.success) return contentResult;
    
    // 3. 構文検証
    const syntaxResult = this.validateSyntax(diffContent);
    return syntaxResult;
  }
  
  private static validateMarkerSequencing(content: string): ValidationResult {
    // roo-codeと同じマーカー検証ロジック
    // <<<<<<< SEARCH → ======= → >>>>>>> REPLACE の順序チェック
  }
}

6. 高度なエラーハンドリング

roo-codeレベルの詳細なエラーハンドリング実装:

// apps/app/src/features/openai/server/services/editor-assistant/enhanced-error-handler.ts
export interface DetailedDiffError {
  type: 'MARKER_SEQUENCE_ERROR' | 'SIMILARITY_TOO_LOW' | 'MULTIPLE_MATCHES' | 'CONTENT_ERROR';
  message: string;
  line?: number;
  details: {
    searchContent: string;
    bestMatch?: string;
    similarity?: number;
    suggestions: string[];
    correctFormat?: string;
    lineRange?: string;
  };
}

export class EnhancedErrorHandler {
  static createMarkerSequenceError(found: string, expected: string, line: number): DetailedDiffError {
    return {
      type: 'MARKER_SEQUENCE_ERROR',
      message: `マーカーシーケンスエラー: 行${line}で '${found}' が見つかりました。期待値: ${expected}`,
      line,
      details: {
        searchContent: found,
        suggestions: [
          'マーカーの順序を確認: <<<<<<< SEARCH → ======= → >>>>>>> REPLACE',
          'コンテンツ内の特殊マーカーをバックスラッシュ(\\)でエスケープ',
          '余分なセパレータや不足しているセパレータがないか確認'
        ],
        correctFormat: `<<<<<<< SEARCH\n:start_line: X\n-------\n[検索内容]\n=======\n[置換内容]\n>>>>>>> REPLACE`
      }
    };
  }

  static createSimilarityError(
    searchContent: string,
    bestMatch: string,
    similarity: number,
    threshold: number,
    startLine?: number
  ): DetailedDiffError {
    const lineRange = startLine ? ` (開始行: ${startLine})` : '';
    const similarityPercent = Math.floor(similarity * 100);
    const thresholdPercent = Math.floor(threshold * 100);

    return {
      type: 'SIMILARITY_TOO_LOW',
      message: `類似度が不十分${lineRange}: ${similarityPercent}% (必要: ${thresholdPercent}%)`,
      details: {
        searchContent,
        bestMatch,
        similarity,
        suggestions: [
          'read_fileツールで最新のファイル内容を確認',
          '空白やインデントの違いを確認',
          '検索内容が正確に一致しているか検証',
          `類似度の閾値を下げることを検討 (現在: ${thresholdPercent}%)`
        ],
        lineRange: startLine ? `行${startLine}から開始` : '全体を対象'
      }
    };
  }
}

7. 設定管理とカスタマイズ

// apps/app/src/features/openai/server/services/editor-assistant/config.ts
export interface EditorAssistantConfig {
  fuzzyThreshold: number;           // デフォルト: 0.8 (80%)
  bufferLines: number;              // デフォルト: 40
  preserveIndentation: boolean;     // デフォルト: true
  enableMiddleOutSearch: boolean;   // デフォルト: true
  maxDiffBlocks: number;           // デフォルト: 10
  stripLineNumbers: boolean;       // デフォルト: true
  enableAggressiveMatching: boolean; // デフォルト: false
}

export const DEFAULT_CONFIG: EditorAssistantConfig = {
  fuzzyThreshold: 0.8,              // roo-codeより緩い設定 (1.0 → 0.8)
  bufferLines: 40,
  preserveIndentation: true,
  enableMiddleOutSearch: true,
  maxDiffBlocks: 10,
  stripLineNumbers: true,
  enableAggressiveMatching: false,
};

// 環境変数による設定のオーバーライド
export function loadConfig(): EditorAssistantConfig {
  const envConfig: Partial<EditorAssistantConfig> = {
    fuzzyThreshold: parseFloat(process.env.GROWI_EDITOR_ASSISTANT_FUZZY_THRESHOLD || '0.8'),
    bufferLines: parseInt(process.env.GROWI_EDITOR_ASSISTANT_BUFFER_LINES || '40'),
    maxDiffBlocks: parseInt(process.env.GROWI_EDITOR_ASSISTANT_MAX_DIFF_BLOCKS || '10'),
  };

  return { ...DEFAULT_CONFIG, ...envConfig };
}

🎛️ 設定とカスタマイズ

ProcessorConfig

interface ProcessorConfig {
  fuzzyThreshold?: number;      // デフォルト: 0.8 (80%)
  bufferLines?: number;         // デフォルト: 40
  preserveIndentation?: boolean; // デフォルト: true
  stripLineNumbers?: boolean;    // デフォルト: true
  enableAggressiveMatching?: boolean; // デフォルト: false
}

環境変数での調整

const config: ProcessorConfig = {
  fuzzyThreshold: parseFloat(process.env.EDITOR_ASSISTANT_FUZZY_THRESHOLD || '0.8'),
  bufferLines: parseInt(process.env.EDITOR_ASSISTANT_BUFFER_LINES || '40'),
  preserveIndentation: process.env.EDITOR_ASSISTANT_PRESERVE_INDENT !== 'false',
};

🧪 テスト戦略

単体テスト

describe('MultiSearchReplaceProcessor', () => {
  it('should handle exact matches', async () => {
    const processor = new MultiSearchReplaceProcessor();
    const result = await processor.applyDiffs(originalContent, [
      { search: 'function test() {', replace: 'function newTest() {' }
    ]);
    expect(result.success).toBe(true);
    expect(result.appliedCount).toBe(1);
  });

  it('should handle fuzzy matches within threshold', async () => {
    // スペースやインデントが微妙に違う場合のテスト
  });

  it('should reject matches below threshold', async () => {
    // 類似度が低すぎる場合のエラーハンドリングテスト
  });
});

統合テスト

describe('Editor Assistant Integration', () => {
  it('should process multiple diffs in correct order', async () => {
    // 複数の変更を正しい順序で処理することを確認
  });

  it('should handle partial failures gracefully', async () => {
    // 一部の変更が失敗した場合の処理を確認
  });
});

📈 パフォーマンス考慮

メモリ最適化

  • 大きなファイルでの文字列処理の最適化
  • 不要なデータの早期解放
  • ストリーミング処理の継続

CPU最適化

  • Middle-out検索による効率化
  • 類似度計算の最適化
  • 早期終了条件の設定

ファイル: technical-implementation-details.md
作成日: 2025-06-17
関連: editor-assistant-refactoring-plan.md