# 빅피클 — 잔여 작업 상세 코딩 계획

> 기준: `CLAUDE.md` 잔여 작업 항목 (2026-05-14)
> 상태: Phase 0~6 완료, Phase 7 + Phase 5 후순위 + 결정 보류 항목이 대상

---

## 현황 요약

| 영역 | 상태 | 비고 |
|------|------|------|
| Phase 0~5 (RAG + 채팅) | ✅ 완료 | Ollama/vLLM 이중 백엔드, SSE 스트리밍, MCP 툴콜 루프(10라운드), KB 업로드/인덱싱 5종 컬렉션 |
| Phase 5 핫픽스 | ✅ 완료 | nl2sql 버그, KB DDL `String.Format` 이슈, 비번 로그 마스킹, `IHttpClientFactory`, plant_context 캐시 |
| Phase 6 (run_sql 가드) | ✅ 완료 | `_validate_sql`, `_apply_sql_guards`, keyword 차단, auto-LIMIT, statement_timeout |
| Phase 6 (보강 도구 5종) | ✅ 완료 | `find_tags`, `query_events`, `active_alarms`, `summarize_events`, `generate_status_report` |
| **Phase 7 (옵션)** | **⏳ 잔여** | NL2SQL 의도 라우터, 대화 요약, 에이전트 모드, KB 청크 미리보기 |
| **Phase 5 후순위** | **⏳ 잔여** | 시계열 미니 스파클라인, 툴 카드 메시지 영구 보존 |
| **결정 보류** | **⏳ 분석** | 현장 재고 데이터 출처, BGE-M3 마이그레이션 |

---

## 1. NL2SQL 의도 라우터 (Phase 7.1)

### 배경
현재 `query_with_nl`은 모든 자연어 질문을 무조건 LLM에 보내 SQL 생성을 시도한다.
"활성 알람 보여줘", "이벤트 요약" 같은 질문도 SQL 경로로 가므로 불필요한 LLM 호출 + SQL
실패 위험이 있다. 의도 라우터가 질문을 분류하여 적절한 MCP 도구로 직접 라우팅한다.

### 설계
```
사용자 질문
    ↓
[_classify_intent()]  ← 키워드/Trie 기반 (ML 불필요)
    ├── "알람/트립" → active_alarms 도구
    ├── "이벤트/요약/보고서" → summarize_events 또는 generate_status_report
    ├── "태그 찾기/검색" → find_tags 도구
    ├── "SQL/조회/데이터" → query_with_nl (기존)
    └── "기타/모름" → query_with_nl (fallback)
```

### 수정 파일

**mcp-server/server.py**
| 위치 | 변경 |
|------|------|
| `_CLASSIFY_RULES` 상수 (신규) | 키워드→도구 매핑 규칙 맵 (예: `{"alarm|트립|경보|trip": "active_alarms", ...}`) |
| `_classify_intent(query) → str` (신규) | 정규식 + 키워드 Trie로 의도 분류, fallback은 `"query_with_nl"` |
| `query_with_nl` 수정 | `query_with_nl` 내부 첫 줄에서 `_classify_intent` 호출, 매칭되면 해당 도구로 위임 |
| `@mcp.tool() classify_intent(question)` 검토 | MCP 도구로 별도 노출할지 결정 (OllamaController의 JSON 폴백 경로에 활용 가능) |

**src/Web/Controllers/OllamaController.cs**
| 위치 | 변경 |
|------|------|
| `ToolGuideKo` | (필요시) `classify_intent` 도구 항목 추가 |

### 분류 규칙 (예시)

```python
_CLASSIFY_RULES = [
    (r'alarm|트립|경보|trip|경보|비상',      'active_alarms'),
    (r'요약|보고서|리포트|summary|report',    'generate_status_report'),
    (r'태그.*찾|검색|찾아줘|찾기|find.*tag',  'find_tags'),
    (r'이벤트|event|로그|기록',               'query_events'),
    (r'SQL|조회|데이터|select|값.*보여줘|수치', 'query_with_nl'),
]
```

### 동작 흐름 (query_with_nl 변경 후)
```
query_with_nl("지금 알람 상황 알려줘")
  → _classify_intent → "active_alarms"
  → active_alarms(area=None) 호출 → 결과 반환
  → (SQL 생성 생략)
```

### 검증
- `python3 -c "import server; print(server._classify_intent('지금 알람 알려줘'))"` → `"active_alarms"`
- `python3 -c "import server; print(server._classify_intent('FIC-6113.PV 값 보여줘'))"` → `"query_with_nl"`
- `python3 -c "import server; print(server._classify_intent('안녕'))"` → `"query_with_nl"` (fallback)

---

## 2. 대화 요약 (Phase 7.2)

### 배경
현재 매 턴 전체 messages 배열을 LLM에 전송한다. 대화가 길어질수록 컨텍스트 윈도우를
초과하거나 토큰 비용이 증가한다. N messages 이상이면 이전 메시지를 요약하여
system prompt에 "지금까지의 대화 요약: ..." 형태로 압축한다.

### 설계
```
messages 길이 > MAX_HISTORY (기본 20)
    ↓
llmSend() 전에 PreprocessMessages()
    ├── 오래된 메시지를 LLM에 요약 요청
    ├── 요약 텍스트를 system prompt에 "대화 요약: ..." 형태로 삽입
    └── 요약된 메시지 제거
```

### 수정 파일

**src/Web/wwwroot/js/app.js**
| 위치 | 변경 |
|------|------|
| `LLM_MAX_HISTORY = 20` (신규 상수) | 요약 트리거 기준 messages 수 |
| `llmPreprocessMessages(messages)` (신규) | messages 길이 체크 → 초과 시 요약 API 호출 |
| `llmSend()` (line 3725) | `llmPreprocessMessages` 호출 추가 (line 3748 앞) |
| session 저장 구조 확장 | `sess.summary` 필드 추가 (요약 텍스트 보관) |
| `llmRenderMessages()` | summary 표시 (접힌 카드) |

**src/Web/Controllers/OllamaController.cs**
| 위치 | 변경 |
|------|------|
| `POST /api/ollama/summarize` (신규) | messages 배열 받아 LLM으로 요약 + 반환 |
| `VllmChatStreamWithTools` | `maxToolRounds` 카운트 유지, 필요시 요약 |

**src/Web/wwwroot/css/style.css**
| `.llm-summary-card` | 요약 표시용 스타일 (접힘/펼침) |

### UI 동작
```
def llmPreprocessMessages(messages):
    if len(messages) <= LLM_MAX_HISTORY: return messages
    # 최근 절반 유지, 오래된 절반 요약 요청
    old_msgs = messages[:-LLM_MAX_HISTORY//2]
    new_msgs = messages[-LLM_MAX_HISTORY//2:]
    
    summary = await api('POST', '/api/ollama/summarize', {messages: old_msgs})
    sess.summary = summary
    
    # system prompt에 요약 주입
    systemPrompt = f"[대화 요약]\n{summary}\n\n[최근 대화]"
    
    return new_msgs
```

### 검증
- 25개 메시지 → 20개 초과 → 요약 트리거
- 요약 이후 처음 메시지에 system prompt에 `[대화 요약]` 포함 확인
- 요약 카드가 UI에 정상 렌더링

---

## 3. 에이전트 모드 (Phase 7.3)

### 배경
현재 툴콜 루프(10라운드)는 LLM이 도구를 호출하면 실행하고 결과를 다시 LLM에 주입한다.
하지만 "이 공정의 문제점을 분석해줘" 같은 복합 태스크는: (1) 활성 알람 조회 →
(2) 관련 태그 이력 조회 → (3) 보고서 생성 순서로 여러 도구를 자율적으로 호출해야 한다.

### 설계
"에이전트 모드" 토글을 채팅 UI에 추가:
- OFF (현행): 단순 툴콜 루프 (LLM이 요청한 도구만 실행)
- ON: 계획→실행→관찰→반복 사이클 (ReAct 패턴)

### 수정 파일

**src/Web/wwwroot/index.html**
| 위치 | 변경 |
|------|------|
| `#llm-agent-mode` 체크박스 (line 1269 옆) | "에이전트 모드" 토글 추가 (MCP 도구 체크박스 옆) |
| 설명 툴팁 | "복합 태스크를 단계별로 계획하고 실행합니다" |

**src/Web/wwwroot/js/app.js**
| 위치 | 변경 |
|------|------|
| `llmAgentMode` 변수 (line 3297 옆) | localStorage persist |
| `llmToggleAgentMode()` (신규) | 토글 변경 시 상태 저장 |
| `llmSend()` | agentMode ON이면 system prompt에 ReAct 프롬프트 주입 |
| `llmRenderMessages()` | agent planning 단계 시각화 |

**src/Web/Controllers/OllamaController.cs**
| 위치 | 변경 |
|------|------|
| `ComposeSystemPrompt()` | agentMode 인자 추가 → ReAct 가이드 포함 |
| `ToolGuideKo` | agent 모드용 tool 사용 설명 추가 |

**ReAct 시스템 프롬프트 (ToolGuideKo에 추가)**
```
[에이전트 모드]
복잡한 질문은 다음 단계로 분해하여 도구를 호출하세요:
1. Thought: 현재 상황과 필요한 정보 파악
2. Action: 적절한 도구 호출 (active_alarms, query_events, query_pv_history 등)
3. Observation: 도구 결과 분석
4. Thought: 다음 단계 결정
5. ...반복...
6. Final Answer: 모든 정보를 종합하여 답변
```

### 동작 흐름
```
사용자: "지금 공장 상황을 분석해줘"

Round 1: Thought → active_alarms() → 결과 분석
Round 2: Thought → find_tags("pump") → 결과 분석  
Round 3: Thought → query_events(area="A") → 결과 분석
Round 4: Thought → generate_status_report() → 최종 보고서 생성

최종: 모든 정보를 종합한 한국어 보고서 출력
```

### 검증
- 에이전트 ON/OFF 토글 localStorage 저장 확인
- ON 상태에서 복합 질문 시 2라운드 이상 도구 호출
- 최종 응답에 모든 단계의 정보가 통합됨

---

## 4. KB 청크 미리보기 UI (Phase 7.4)

### 배경
현재 KB 관리 탭에서 문서 목록은 보이지만, 각 문서의 청크 내용을 볼 수 없다.
인덱싱 결과를 눈으로 확인할 수 없어 디버깅과 품질 검증이 어렵다.

### 설계
Qdrant에서 `doc_id`로 청크를 조회하는 API → 프론트엔드에서 접이식 카드로 표시.

### 수정 파일

**src/Infrastructure/Kb/KbQdrantClient.cs**
| 위치 | 변경 |
|------|------|
| `GetChunksByDocIdAsync(docId, collection)` (신규) | Qdrant Scroll API로 doc_id 필터, payload(text, chunk_kind, locator) 반환 |

**src/Web/Controllers/KbController.cs**
| 위치 | 변경 |
|------|------|
| `GET /api/kb/documents/{id}/chunks` (신규) | admin 인증 필요, `KbQdrantClient.GetChunksByDocIdAsync` 호출 |

**src/Web/wwwroot/js/app.js**
| 위치 | 변경 |
|------|------|
| `kbShowChunks(docId)` (신규) | `/api/kb/documents/{id}/chunks` 호출 → 모달에 렌더 |
| `kbRenderChunks(chunks)` (신규) | 청크 목록을 접이식 카드로 표시 (chunk_kind 배지, text 미리보기 200자, locator 표시) |
| `kbRenderDocs` | "청크 미리보기" 버튼 추가 (청크 수 > 0인 경우) |

**src/Web/wwwroot/index.html**
| 위치 | 변경 |
|------|------|
| 청크 미리보기 모달 (kb-upload-modal 다음) | #kb-chunk-modal: 모달 내 청크 리스트 + 닫기 버튼 |

**src/Web/wwwroot/css/style.css**
| `.kb-chunk-card` | 청크 카드 (테두리, 접힘/펼침) |
| `.kb-chunk-badge` | chunk_kind 배지 (table/page/text 등) |
| `.kb-chunk-locator` | locator 표시 (파일 내 위치) |

### API 응답 형식
```json
GET /api/kb/documents/{id}/chunks
{
  "success": true,
  "docId": "uuid",
  "collection": "plant_operation",
  "count": 12,
  "chunks": [
    {
      "text": "청크 내용...",
      "chunk_kind": "table",
      "locator": "Sheet1, Row 5-10",
      "score": null
    }
  ]
}
```

### UI 동작
```
[d832a1f2] 온도센서 교체 매뉴얼.pdf  | plant_operation | indexed | 12청크 | [청크보기]
                                                    ↓ 클릭
┌─ 청크 미리보기 ─────────────────────────────────┐
│ [table] Sheet1, Row 5-10                        │
│ ┌───────┬──────┬──────┐                         │
│ │ 온도  │ 범위 │ 오차 │                         │
│ ├───────┼──────┼──────┤                         │
│ │ PT100 │ -200…│ ±0.1 │                         │
│ └───────┴──────┴──────┘                         │
│                                                  │
│ [text] 페이지 2, 3번째 문단                      │
│ RTD 센서는 저항 온도 센서로...  [펼치기]          │
└──────────────────────────────────────────────────┘
```

### 검증
- 청크 0건 문서 → 버튼 미표시
- 청크 12건 문서 → 버튼 표시 → 클릭 시 모달 오픈
- 모달 내 청크 카드 접기/펼치기 동작

---

## 5. 시계열 미니 스파클라인 (Phase 5 후순위)

### 배경
Phase 5 설계서(C5)에서 "표 자동 렌더링"은 완료했으나 "시계열 스파클라인"은 보류됨.
uPlot은 이미 fastRecord 탭에서 사용 중 (`index.html:1535` 로드 완료).

### 설계
`query_pv_history` 또는 `run_sql` 결과에서 timestamp + numeric value 컬럼 감지 시
uPlot 미니 차트(스파클라인)를 자동 렌더링.

### 수정 파일

**src/Web/wwwroot/js/app.js**
| 위치 | 변경 |
|------|------|
| `llmRenderSparkline(containerId, data)` (신규) | uPlot 미니차트 생성 (height: 80px, grid 없음, tooltip만) |
| `llmRenderToolPayload()` (line 3587) | JSON 응답에 timestamp+value 패턴 감지 시 스파클라인 렌더링 옵션 제공 |
| `llmDetectTimeSeries(data)` (신규) | 데이터가 `[{timestamp, value}]` 또는 `[{recorded_at, pv}]` 형태인지 감지 |

**src/Web/wwwroot/css/style.css**
| `.llm-sparkline-box` | 스파클라인 컨테이너 (padding, border, max-width) |
| `.llm-sparkline-toggle` | "📈 추세 보기" 토글 버튼 |

### 스파클라인 생성 코드 (예시)
```javascript
function llmRenderSparkline(containerId, data, valueKey, timeKey) {
  const times = data.map(r => new Date(r[timeKey || 'timestamp']).getTime() / 1000);
  const vals = data.map(r => parseFloat(r[valueKey || 'value']));
  
  const opts = {
    width: 280, height: 64,
    cursor: { show: true },
    select: { show: false },
    axes: [{ show: false }, { show: false }],
    series: [
      { label: '' },
      { label: '', stroke: 'var(--accent)', width: 1, points: { show: false } }
    ]
  };
  
  new uPlot(opts, [times, vals], document.getElementById(containerId));
}
```

### 동작 흐름
```
query_pv_history 결과
  → llmRenderToolPayload에서 {success, data:[{tag_name, timestamp, value}]} 감지
  → "📈 추세 보기" 버튼 표시
  → 클릭 시 llmRenderSparkline() 실행
  → uPlot 미니차트가 툴 카드 내에 렌더링
```

### 검증
- 시계열 데이터 2건 미만 → 차트 미표시
- 3건 이상 → "📈 추세 보기" 버튼 → 클릭 시 uPlot 차트 렌더링
- 툴 카드 접기/펼치기와 호환

---

## 6. 툴 카드 메시지 영구 보존 (Phase 5 후순위)

### 배경
현재 툴 카드(`llm-tool-card`)는 SSE `tool_start`/`tool_result` 이벤트로 DOM에 직접
삽입되지만, `llmRenderMessages()`가 `sess.messages`만으로 전체 메시지 영역을
innerHTML=...로 재생성하므로 툴 카드가 사라진다.

즉, 페이지 새로고침이나 탭 전환 후 이전 대화를 열면 툴 카드가 모두消失.

### 설계
`sess.messages`에 `tool_call` 타입 메시지를 저장하고, `llmRenderMessages()`에서
툴 카드를 재생성할 수 있도록 구조화.

### 수정 파일

**src/Web/wwwroot/js/app.js**

| 위치 | 변경 |
|------|------|
| **데이터 모델** | `sess.messages` item에 type: `"text"` \| `"tool_call"` 속성 추가 |
| `llmSend()` (line 3758) | assistant 메시지에 `toolCalls: []` 배열 추가 (초기 빈 배열) |
| SSE `tool_start` 처리 (line 3838) | `assistantMsg.toolCalls.push({id, name, args, ok:null, payload:null})` |
| SSE `tool_result` 처리 (line 3846) | 해당 toolCalls 항목 업데이트 (`ok`, `payload`, `preview`, `length`) |
| `llmRenderMessages()` (line 3418) | 메시지에 `toolCalls` 배열 있으면 툴 카드 렌더링 |
| `llmRenderToolCards(toolCalls)` (신규) | toolCalls 배열 → 툴 카드 HTML 생성 |
| `llmSaveSessions()` | 변경 없음 (messages 배열에 포함되어 자동 저장) |

### 데이터 구조 (localStorage)
```javascript
sess.messages = [
  { role: 'user', content: '지금 알람 보여줘' },
  { 
    role: 'assistant', 
    content: '현재 3개의 활성 알람이 있습니다...',
    toolCalls: [
      { id: 'tc_1_xxx', name: 'active_alarms', args: '{}', ok: true, payload: '{...}' }
    ]
  }
]
```

### 수정 상세

**llmSend() - toolCalls 배열 초기화 (line 3758)**
```javascript
// 변경 전
const assistantMsg = { role: 'assistant', content: '' };

// 변경 후
const assistantMsg = { role: 'assistant', content: '', toolCalls: [] };
```

**SSE `tool_start` 핸들러 (line 3838)**
```javascript
// 추가
assistantMsg.toolCalls.push({ id: t.id, name: t.name, args: t.args, ok: null, payload: null });
```

**SSE `tool_result` 핸들러 (line 3846)**
```javascript
// 추가
const tc = assistantMsg.toolCalls.find(x => x.id === t.id);
if (tc) { tc.ok = t.ok; tc.payload = t.payload; }
```

**llmRenderMessages() - toolCalls 렌더링 (line 3449 이후)**
```javascript
// 각 메시지의 content 다음에 toolCalls 렌더링
if (m.toolCalls && m.toolCalls.length > 0) {
  const cardsHtml = m.toolCalls.map(tc => {
    const statusClass = tc.ok === null ? 'running' : (tc.ok ? 'ok' : 'err');
    const statusText = tc.ok === null ? '실행 중…' : (tc.ok ? '완료' : '실패');
    // ... 툴 카드 HTML 생성
  }).join('');
  html += `<div class="llm-tool-cards">${cardsHtml}</div>`;
}
```

### 마이그레이션 (기존 세션 호환)
기존 localStorage `llmSessions`에는 `toolCalls` 필드가 없음. `llmRenderMessages()`에서
`toolCalls`가 undefined/null이면 툴 카드 렌더링 생략 (기존처럼 동작).

### 검증
1. 새 채팅에서 도구 호출 → SSE 완료 후 localStorage 확인 → `toolCalls` 배열 저장됨
2. F5 새로고침 → 이전 대화 열기 → 툴 카드가 정상 렌더링
3. 기존 세션(툴 카드 없는 메시지) → 오류 없음
4. 저장/로드 시 JSON 용량 증가 확인 (toolCalls 1개당 약 200바이트)

---

## 7. 현장 재고 데이터 출처 (결정 보류 — 분석만)

### 현황
Phase 0 설계서(G3)에서 "현장 재고 데이터 자체 없음"으로 식별됨. 코드 작업 없음.

### 분석 방향
| 측면 | 검토 사항 |
|------|----------|
| 데이터 성격 | 예비품 재고, 소모품, 교체 이력, 위치 정보 |
| 가능한 출처 | 별도 엑셀 관리 → KB 업로드, ERP 연동 API, 수동 입력 |
| KB 활용 | plant_operation 또는 report 컬렉션에 엑셀 업로드로 즉시 해결 가능 |
| 우선순위 | 낮음 (Phase 0~6 안정화 후 검토) |

### 권장
현장 재고 데이터는 기존 KB 시스템에 엑셀/CSV를 `plant_operation` 컬렉션으로
업로드하여 즉시 RAG 검색 가능. 별도 개발 불필요.

---

## 8. 임베딩 모델 BGE-M3 마이그레이션 (결정 보류 — 계획만)

### 현황
현재 `nomic-embed-text` (768차원, Ollama) 사용 중. BGE-M3 (1024차원)는
다국어(한국어) 성능이 더 우수하나 Qdrant 컬렉션 재생성이 필요.

### 마이그레이션 계획 (향후 실행 시)

| 단계 | 작업 | 영향 |
|------|------|------|
| 1 | BGE-M3 Ollama에 pull (`ollama pull bge-m3`) | 서버 리소스 추가 사용 |
| 2 | `KbEmbeddingClient.cs` 모델명 변경 (settings.json) | 임베딩 차원 768→1024 |
| 3 | Qdrant 컬렉션 5개 재생성 (기존 삭제 + vector_size=1024로 recreate) | 기존 인덱스 전부 소멸 |
| 4 | 전체 문서 재인덱싱 | 시간 소요 (문서 수에 비례) |
| 5 | `_embed()` consistency check | 1024차원 정상 출력 확인 |

### 위험
- 기존 Qdrant 컬렉션 삭제 시 모든 KB 검색 불가 (재인덱싱 완료까지)
- 1024차원으로 변경 시 메모리 사용량 증가 (약 33%)
- BGE-M3의 한국어 성능 향상이 임계값 이상인지 사전 평가 필요

### 권장
긴급하지 않음. nomic-embed-text로 Phase 7 운영 후, BGE-M3 안정성 확인 후
점진적 마이그레이션. `KbEmbeddingClient.cs`에 dimension을 환경변수/설정에서
읽도록 개선하는 선행 작업 권장.

---

## 실행 우선순위

| 순위 | 작업 | 예상 시간 | 영향도 |
|------|------|-----------|--------|
| 1 | **툴 카드 영구 보존** | 2~3h | HIGH — UX 품질, 데이터 손실 방지 |
| 2 | **KB 청크 미리보기 UI** | 2~3h | MED — 관리자 디버깅 편의 |
| 3 | **시계열 스파클라인** | 1~2h | MED — 데이터 가시성 향상 |
| 4 | **NL2SQL 의도 라우터** | 1~2h | MED — 불필요한 SQL 호출 감소 |
| 5 | **대화 요약** | 1~2h | LOW — 장기 대화 안정성 |
| 6 | **에이전트 모드** | 2~3h | LOW — 고급 기능, Phase 7 후순위 |
| 7 | **BGE-M3 분석/계획 수립** | — | 보류 |
| 8 | **현장 재고 데이터 출처** | — | 보류 |

---

## 빌드/검증 명령

```bash
# .NET 빌드
dotnet build src/Web/ExperionCrawler.csproj

# Python syntax check
python3 -m py_compile mcp-server/server.py mcp-server/worker/nl2sql_worker.py

# Python import check
cd mcp-server && python3 -c "import server"
```

## 런타임 셋업 (코드 외)
- mcp-server 재시작 (의도 라우터 추가 시)
- 브라우저 캐시 무효화 (Ctrl+F5)
