feat: 법률앱 — 일반 대화를 사건으로 전환

상담하듯 일반 대화로 물어보다가 사건으로 승격하고 싶을 때 쓴다. 채팅 헤더의
📁 버튼(사건 미선택일 때만 노출).

요약만 옮기면 사건 안에서 이어 물을 때 맥락이 끊기므로 **대화 세션 파일 자체를**
사건 세션(`lw_case_<id>`)으로 옮긴다. 그래서 서버에 세션 이동 API를 새로 만들었다
(POST /api/chat/sessions/:id/move). 파일명으로 그대로 쓰이므로 세션 ID는
[A-Za-z0-9_-]만 허용하고, 목적지가 이미 있으면 409로 거부한다 — 덮어쓰면 다른
사건의 대화가 날아간다.

실패 처리는 되돌릴 수 없는 쪽을 기준으로 잡았다:
 - 제목/개요 추출이 실패해도 진행한다. 첫 질문을 제목으로 쓴다. 이름은 나중에
   고치면 되지만 대화는 못 되찾는다.
 - 세션 이동이 실패하면 사건을 만들지 않는다. 사건만 생기고 대화가 안 따라오면
   일반창에 그대로 남아 중복이 된다.

버튼 표시 토글을 updateToCaseBtn()으로 빼서 부트스트랩에서도 부른다.
처음엔 ensureChatContextForCase 안에만 넣었는데, 그 함수는 모드 전환·사건
선택·대화 지우기에서만 돌아서 최초 로드에는 안 걸렸다 — 버튼이 계속 숨어 있었다.

가드 검증: 없는 세션 404 / 경로조작 from·to 400 / 인증 없음 401.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-08-21 11:53:51 +09:00
co-authored by Claude Opus 5
parent d60019f004
commit a9040b7222
2 changed files with 122 additions and 0 deletions
@@ -74,6 +74,38 @@ export function registerChatSessionRoutes(app: express.Application): void {
}
});
// POST /api/chat/sessions/:id/move — rename a session file (대화를 사건으로 전환).
//
// 법률앱에서 "일반 대화"로 상담하다가 사건으로 승격할 때 쓴다. 대화를 요약만 옮기면
// 이어서 물을 때 맥락이 끊기므로, 세션 파일 자체를 사건 세션 이름으로 옮긴다.
// 사건 세션 ID는 앱이 `lw_case_<caseId>` 규칙으로 만든다.
app.post('/api/chat/sessions/:id/move', async (req, res) => {
const user = (req as any).user;
const from = String(req.params.id);
const to = String((req.body || {}).to || '');
// 파일명으로 그대로 쓰이므로 경로 조작 문자를 원천 차단한다.
const SAFE = /^[A-Za-z0-9_-]+$/;
if (!SAFE.test(from) || !SAFE.test(to)) {
res.status(400).json({ success: false, error: 'invalid session id' });
return;
}
try {
const configDir = getConfig().getConfigDir();
const sessDir = user?.username
? path.join(configDir, 'users', user.username, 'sessions')
: path.join(configDir, 'sessions');
const src = path.join(sessDir, `${from}.json`);
const dst = path.join(sessDir, `${to}.json`);
if (!fs.existsSync(src)) { res.status(404).json({ success: false, error: 'not found' }); return; }
// 목적지가 이미 있으면 덮어쓰지 않는다 — 다른 사건의 대화를 날릴 수 있다.
if (fs.existsSync(dst)) { res.status(409).json({ success: false, error: 'destination exists' }); return; }
await fsp.rename(src, dst);
res.json({ success: true, from, to });
} catch (e: any) {
res.status(500).json({ success: false, error: String(e?.message || e) });
}
});
// DELETE /api/chat/sessions/:id — delete a session file from disk
app.delete('/api/chat/sessions/:id', async (req, res) => {
const user = (req as any).user;
+90
View File
@@ -276,6 +276,11 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
.lw-tl-del:hover{opacity:1 !important;color:#ef4444;}
.lw-tl-empty{text-align:center;color:var(--muted);font-size:12px;padding:30px 0;}
</style>
<link rel="stylesheet" href="/vendor/katex/katex.min.css">
<script src="/vendor/katex/katex.min.js"></script>
<script src="/vendor/katex/auto-render.min.js"></script>
<script src="/vendor/katex/mhchem.min.js"></script>
<script src="../js/app/katex-inline.js"></script>
<script src="../js/voice/voice-utils.js"></script>
</head>
<body>
@@ -544,6 +549,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
<select class="lw-model-sel" id="model-sel" onchange="saveModel()">
<option value="">기본 모델</option>
</select>
<button class="lw-chat-clear" id="lw-to-case-btn" onclick="convertChatToCase()" title="지금 대화를 새 사건으로 만들기" style="display:none">📁</button>
<button class="lw-chat-clear" onclick="clearChat()" title="대화 지우기">🗑</button>
</div>
<div class="lw-msgs" id="chat-msgs"></div>
@@ -665,12 +671,21 @@ async function loadHistory(){
// keeps each case's AI conversation separate from the others and from
// general 법령/판례 검색 chat, same pattern as detective-app.
let _chatCtxCaseId='__unset__';
// "사건으로" 버튼은 일반 대화일 때만 의미가 있다 — 이미 사건 안이면 옮길 곳이 없다.
// ensureChatContextForCase는 모드 전환·사건 선택 때만 돌아서 최초 로드에는 안 걸린다.
// 그래서 별도 함수로 두고 부트스트랩에서도 부른다(안 그러면 버튼이 계속 숨어 있다).
function updateToCaseBtn(){
const b=document.getElementById('lw-to-case-btn');
if(b)b.style.display=currentCaseId?'none':'';
}
async function ensureChatContextForCase(){
if(_chatCtxCaseId===currentCaseId)return;
_chatCtxCaseId=currentCaseId;
const c=cases.find(x=>x.id===currentCaseId);
const label=document.getElementById('lw-chat-ctx-label');
if(label)label.textContent=c?c.title||'제목 없음':'일반';
updateToCaseBtn();
document.getElementById('chat-msgs').innerHTML='';
await loadHistory();
}
@@ -857,6 +872,77 @@ function selectCase(id){
if(window.innerWidth<=1024)mobCloseAll();
}
// 지금 보고 있는 "일반" 대화를 새 사건으로 승격한다.
//
// 요약만 옮기면 이어서 물었을 때 맥락이 끊기므로, 대화 세션 파일 자체를 사건 세션
// (`lw_case_<id>`)으로 옮긴다(POST /api/chat/sessions/:id/move). 제목·개요는 대화 내용에서
// AI가 뽑되, 실패해도 사건 생성 자체는 진행한다 — 이름은 나중에 고치면 되지만 대화를
// 잃는 건 되돌릴 수 없다.
async function convertChatToCase(){
if(currentCaseId){alert('이미 사건 대화입니다. 일반 대화에서만 사용할 수 있습니다.');return;}
const fromSid=getSessionId();
if(!fromSid){alert('대화 세션을 찾을 수 없습니다.');return;}
// 옮길 내용이 실제로 있는지 먼저 본다 — 빈 대화로 사건을 만들면 껍데기만 남는다.
let msgs=[];
try{
const r=await fetch('/api/chat/sessions/'+encodeURIComponent(fromSid),{headers:authH()});
if(r.ok){const d=await r.json();msgs=(d.history||[]).filter(m=>m.role==='user'||m.role==='assistant');}
}catch{}
if(!msgs.length){alert('아직 대화 내용이 없습니다.');return;}
if(!confirm(`이 대화(${msgs.length}개 메시지)를 새 사건으로 옮길까요?\n일반 대화창은 비워집니다.`))return;
const btn=document.getElementById('lw-to-case-btn');
if(btn){btn.disabled=true;btn.textContent='⏳';}
try{
// 1) 대화에서 사건명/개요 추출 (실패해도 계속 진행)
const transcript=msgs.slice(-24).map(m=>(m.role==='user'?'상담자: ':'AI: ')+String(m.content||'').slice(0,600)).join('\n');
let title='',notes='';
try{
const out=await callChatRaw('다음은 법률 상담 대화입니다. 이 상담을 사건으로 등록하려 합니다.\n'
+'첫 줄에 "제목: " 으로 시작하는 30자 이내 사건명을, 그 다음 줄부터 "개요:" 로 시작해 핵심 쟁점과 사실관계를 5줄 이내로 정리해 주세요.\n'
+'대화에 없는 내용을 지어내지 마세요.\n\n'+transcript);
if(out){
const mt=out.match(/제목\s*[::]\s*(.+)/);
if(mt)title=mt[1].trim().slice(0,60);
const mo=out.split(/개요\s*[::]/)[1];
if(mo)notes=mo.trim();
}
}catch{}
// 2) 사건 생성 — 제목 추출이 실패하면 첫 질문을 제목으로 쓴다
if(!title){
const firstUser=msgs.find(m=>m.role==='user');
title=(firstUser?String(firstUser.content||'').replace(/\s+/g,' ').slice(0,30):'')||'상담 사건';
}
const c=normalizeCase({id:genId(),title,status:'진행중',
notes:notes||transcript.slice(0,2000),createdAt:new Date().toISOString()});
// 3) 대화 세션을 사건 세션으로 이동 — 여기서 실패하면 사건을 만들지 않는다.
// 사건만 만들고 대화가 안 따라오면 일반창에 그대로 남아 중복이 생긴다.
const toSid='lw_case_'+c.id;
const mv=await fetch('/api/chat/sessions/'+encodeURIComponent(fromSid)+'/move',
{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({to:toSid})});
if(!mv.ok){
const err=await mv.json().catch(()=>({}));
throw new Error(err.error||('HTTP '+mv.status));
}
cases.unshift(c);
await saveCases();
// 일반 대화는 세션 파일이 옮겨져 비었다 — 다음에 쓸 새 세션 ID를 발급받는다.
_sessionId='lw_'+Math.random().toString(36).slice(2,12);
try{await fetch('/api/user/app-sessions',{method:'PUT',headers:authH({'Content-Type':'application/json'}),
body:JSON.stringify({appKey:APP_KEY,sessionId:_sessionId})});}catch{}
selectCase(c.id);
addMsg('system','✅ 대화를 사건 "'+escHtml(c.title)+'"으로 옮겼습니다.');
}catch(e){
alert('사건으로 전환하지 못했습니다: '+(e.message||e)+'\n대화는 그대로 남아 있습니다.');
}finally{
if(btn){btn.disabled=false;btn.textContent='📁';}
}
}
function newCase(){
const c=normalizeCase({id:genId(),title:'새 사건',status:'진행중',notes:'',createdAt:new Date().toISOString()});
cases.unshift(c);
@@ -1579,6 +1665,9 @@ function addMsg(role,html){
const div=document.createElement('div');div.className='lw-msg '+role;
const b=document.createElement('div');b.className='lw-bubble';b.innerHTML=html;
div.appendChild(b);
// 수식은 DOM에 들어간 뒤에 렌더링해야 한다(innerHTML은 <script>를 실행하지 않는다).
// TTS 버튼이 b.innerText를 읽으므로 그보다 먼저 돌려서 수식이 읽을 수 있는 글자가 되게 한다.
if(role!=='user'&&window.renderKatexIn)window.renderKatexIn(b);
if(role==='assistant'){const tb=document.createElement('button');tb.className='app-tts-btn';tb.title='음성으로 듣기';tb.textContent='🔊';tb.onclick=function(){playTTSText(tb,b.innerText);};div.appendChild(tb);}
msgs.appendChild(div);msgs.scrollTop=msgs.scrollHeight;
return b;
@@ -1701,6 +1790,7 @@ function autoResize(el){el.style.height='auto';el.style.height=Math.min(el.scrol
await initSessionId();
await loadHistory();
await Promise.all([loadSkill(),loadModels()]);
updateToCaseBtn();
addMsg('system','⚖️ 법률 어시스턴트입니다. 법령/판례 검색이나 법률 질문을 입력하세요.');
initAppVoice(sendMessage);
document.getElementById('chat-input').focus();