/* ============================================================================ * 서명전에 — iOS Issue Detail (sheet content) * ========================================================================== */ // 두 가지 하이라이트 마커를 모두 로 변환: // 1) [[K]]...[[/K]] — Result.jsx 의 _renderHighlighted 와 통일된 신규 마커 // 2) **...** — 기존 markdown bold 마커 (호환 유지) // LLM 이 둘 중 어느 쪽을 써도 안전하게 렌더된다. function renderQuoteWithHighlights(text) { if (!text) return null; const parts = String(text).split(/(\[\[K\]\][^\[]*?\[\[\/K\]\]|\*\*[^*]+\*\*)/g); return parts.map((p, i) => { if (p.startsWith("[[K]]") && p.endsWith("[[/K]]")) { return {p.slice(5, -6)}; } if (/^\*\*[^*]+\*\*$/.test(p)) { return {p.slice(2, -2)}; } return {p}; }); } /* reason(설명) 단락을 본문 + TL;DR(풀어쓴 한마디) 두 덩어리로 쪼갠다. * LLM 이 "쉽게 말해 …", "한마디로 …" 같은 요약 문장을 덧붙여 주는 편인데, * 기존에는 전부 한 회색 단락으로 묶여 있어서 "이 조항이 뭔 말이냐" 라는 * 사용자 입장의 핵심 줄이 묻혀 있었다. 이 함수는: * 1) 문두에 가장 먼저 등장하는 요약 트리거 어구를 찾고 * 2) 그 앞 → body, 뒤 → tldr 로 분리해 둘 다 반환 * 트리거가 없으면 전체를 body 한 덩어리로. * 외부에선 `{ body, tldr }` 형태로 받아 시각 분리 렌더링. */ function splitReasonChunks(raw) { const text = String(raw || "").trim(); if (!text) return { body: "", tldr: "" }; // 앞쪽일수록 우선 (본문이 있고 그 뒤에 TL;DR 이 오는 구조가 대다수). const markers = ["쉽게 말해", "한마디로", "간단히 말해", "요약하면", "정리하면", "즉,"]; let bestIdx = -1; for (const m of markers) { const idx = text.indexOf(m); if (idx > 0 && (bestIdx === -1 || idx < bestIdx)) bestIdx = idx; } if (bestIdx > 0) { const body = text.slice(0, bestIdx).replace(/[\s·,;.]+$/u, "").trim(); const tldr = text.slice(bestIdx).trim(); if (body && tldr) return { body, tldr }; } return { body: text, tldr: "" }; } /* ──────────────────────────────────────────────────────────────────── * step 후처리 헬퍼 (의미적 dedup + off-topic 필터). * * 회귀 케이스: * (A) 도어록 50만원 정액 배상 이슈 — LLM 이 4개 step 을 만드는데 3·4번이 * 사실상 같은 메시지("50만원이 과도하다"). 사용자: "한 줄로 합쳐 줘". * (B) 택배 분실·파손 이슈 — step 3 에 "특히 대금 지급일, 해지 절차, * 손해배상 범위를 계약서에 구체적인 숫자와 날짜로 적어 달라" 같은 * 완전히 무관한 일반 권고가 끼어들어옴. 사용자: "택배랑 상관 없는 * 이야기". * * 두 케이스를 모두 잡되 정상 step 은 보존: * ① _stepTokens(s) — 한글 2자+ 명사 + 숫자(만/원/일 등) 추출, stop word 제거 * ② _jaccard(a, b) — 두 set 의 교집합/합집합. 1.0=동일, 0=무관 * ③ _isShortEncouragement(s) — 마무리 격려/안내 (35자 이하 + 권유 어미) 보호 * ──────────────────────────────────────────────────────────────────── */ const _STEP_STOP_WORDS = new Set([ "요청", "수정", "변경", "추가", "삭제", "공정", "기재", "포함", "확인", "계약서", "조항", "특약", "문구", "내용", "부분", "사항", "부탁", "말씀", "답변", "요구", "제안", "협의", "합의", "필요", "가능", "정상", "구체", "정확", "상황", "경우", "관련", "다음", "이렇게", "그렇게", "이거", "이건", "저거", "오늘", "지금", "원래", "보통", "대부분", "사람", "본인", ]); function _stepTokens(s) { if (!s) return new Set(); const out = new Set(); const numRe = /(\d[\d,]*\s*(?:만\s*원|원|일|개월|년|배|%|회))/g; let m; while ((m = numRe.exec(s)) !== null) { out.add(m[0].replace(/\s+/g, "")); } const wordRe = /[가-힣]{2,}/g; while ((m = wordRe.exec(s)) !== null) { const w = m[0]; if (_STEP_STOP_WORDS.has(w)) continue; out.add(w); } return out; } /* 한국어 조사·어미 탓에 같은 어휘가 다른 토큰으로 보이는 회귀 방어: "관리사무소가" / "관리사무소와" / "관리사무소" 가 본래 같은 단어인데 `[가-힣]{2,}` 단순 매칭은 모두 별개 토큰으로 만든다. 두 토큰의 공통 prefix 가 3자 이상이고 길이 차이가 2 이하면 "같은 어휘" 로 간주. */ function _tokenSimilar(a, b) { if (a === b) return true; if (a.startsWith(b) || b.startsWith(a)) { return Math.min(a.length, b.length) >= 3; } let i = 0; const lim = Math.min(a.length, b.length); while (i < lim && a[i] === b[i]) i += 1; return i >= 3 && Math.abs(a.length - b.length) <= 2; } function _hasSimilarToken(set, target) { for (const x of set) { if (_tokenSimilar(x, target)) return true; } return false; } function _jaccard(a, b) { if (a.size === 0 && b.size === 0) return 0; // prefix-기반 매칭: a 의 각 토큰이 b 안의 비슷한 토큰과 매칭되는지 let inter = 0; const matched = new Set(); for (const x of a) { for (const y of b) { if (matched.has(y)) continue; if (_tokenSimilar(x, y)) { inter += 1; matched.add(y); break; } } } const uni = a.size + b.size - inter; return uni === 0 ? 0 : inter / uni; } function _isShortEncouragement(s) { const t = String(s || "").trim(); if (t.length > 35) return false; return /(?:할게요|줄\s*거예요|좋아요|보세요|해\s*줘요|봐\s*줘요|봐요)\.?$/.test(t); } function IssueDetail({ issue, onGoPreview }) { if (!issue) return null; /* 칩 색은 legal_status 우선으로 결정 — Result hero 의 heroKind 와 동일 매핑 (void→alert 빨강 / unfair→warn 주황 / negotiable→safe 초록). 이렇게 해야 "이슈 리스트에서 보던 색" 과 "이슈 상세 칩 색" 이 일치해서 사용자 혼란이 없음. legal_status 가 없으면 severity 로 폴백. 라벨 텍스트는 기존처럼 "고위험 · 한쪽에 불리" 조합 그대로 유지 — 정보량 손실 없음. */ const toneByLegal = issue.legal_status === "void" ? { bg: "var(--alert-soft)", color: "var(--alert)" } : issue.legal_status === "unfair" ? { bg: "var(--warn-soft)", color: "var(--warn)" } : issue.legal_status === "negotiable" ? { bg: "var(--safe-soft)", color: "var(--safe)" } : null; const toneBySev = issue.severity === "high" ? { bg: "var(--alert-soft)", color: "var(--alert)" } : issue.severity === "medium" ? { bg: "var(--warn-soft)", color: "var(--warn)" } : { bg: "var(--safe-soft)", color: "var(--safe)" }; const sevLabel = issue.severity === "high" ? "고위험" : issue.severity === "medium" ? "주의" : "경미"; const sev = { ...(toneByLegal || toneBySev), label: sevLabel }; const statusLabel = issue.legal_status === "void" ? "효력 없을 수도" : issue.legal_status === "unfair" ? "한쪽에 불리" : issue.legal_status === "negotiable" ? "조율할 부분" : ""; /* step 분할 — 마침표(. 。) + 공백 으로 split 후, 따옴표 짝이 안 맞는 인접 step 들을 다시 merge. 그렇지 않으면 LLM 이 만든 인용문 ("'갑자기 오시면 곤란해요. 하루 전에 말씀해 주세요?'라고 해보세요") 이 마침표 위치에서 잘려 step 4·5 가 따옴표 한 짝씩 외롭게 나뉨. 같은 의미라면 짧은 step 도 인접 시 자동으로 합쳐서 step 수를 줄임. */ const steps = (() => { const raw = (issue.suggestion || "") .split(/(?<=[.。?!])\s+/) .map((s) => s.trim()) .filter(Boolean); /* 모든 종류의 따옴표 (영문 / 한글 / curly) 갯수 파악. 짝수 = 닫힘. */ const _quoteCount = (s) => { let n = 0; for (const ch of s) { if (ch === '"' || ch === "'" || ch === "\u201c" || ch === "\u201d" || ch === "\u2018" || ch === "\u2019" || ch === "\u300c" || ch === "\u300d" || ch === "\u300e" || ch === "\u300f") { n += 1; } } return n; }; /* 1단계: 따옴표 짝이 안 맞으면 다음과 merge */ const merged = []; let buf = ""; for (const part of raw) { const cur = buf ? (buf + " " + part) : part; if (_quoteCount(cur) % 2 === 1) { // 아직 닫히지 않은 따옴표 — 다음 step 과 합치기 위해 buffer 에 보관 buf = cur; } else { merged.push(cur); buf = ""; } } if (buf) merged.push(buf); // 끝까지 안 닫힌 따옴표가 있으면 그대로 flush /* 2단계: 너무 짧은 인접 step (30자 미만) 들이 의미 단절 없이 이어진다면 추가 merge — "원하면 한 줄 넣어두면 좋아요." (짧은 후속 권유) 같은 단편을 앞 step 에 흡수. 단 문장이 명확히 다른 액션이면 보존. */ const sentencePass = []; for (const s of merged) { const last = sentencePass[sentencePass.length - 1]; if (last && last.length < 30 && s.length < 30 && /(?:해요|좋아요|보세요|어때요|할게요)\.?$/.test(last)) { sentencePass[sentencePass.length - 1] = last + " " + s; } else { sentencePass.push(s); } } /* 3단계: 의미적 dedup — 두 step 이 같은 얘기를 두 번 하면 짧은 쪽을 drop. 기준 두 가지를 OR 로 적용: (a) 토큰 jaccard ≥ 0.5 — 표현은 달라도 단어 set 이 절반 이상 겹침 (b) 같은 숫자 토큰("50만원" 등) 공유 + jaccard ≥ 0.15 — 정확한 금액을 양쪽이 동시에 언급하면 같은 주제일 가능성이 매우 높음 회귀: 도어록 50만원 이슈에서 "50만원은 너무 과도하다" + "50만원은 시세보다 2~3배 비싸다" — 다른 형용사를 써서 jaccard 0.16 인데 사용자는 "한 줄로 합쳐 줘" 요청. (b) 룰이 이 케이스를 잡는다. */ const tokenized = sentencePass.map((s) => ({ text: s, toks: _stepTokens(s), nums: [..._stepTokens(s)].filter((t) => /^\d/.test(t)), })); const drop = new Set(); for (let i = 0; i < tokenized.length; i++) { if (drop.has(i)) continue; for (let j = i + 1; j < tokenized.length; j++) { if (drop.has(j)) continue; const sim = _jaccard(tokenized[i].toks, tokenized[j].toks); const sharedNum = tokenized[i].nums.length > 0 && tokenized[j].nums.length > 0 && tokenized[i].nums.some((n) => tokenized[j].nums.includes(n)); const isDup = sim >= 0.5 || (sharedNum && sim >= 0.15); if (isDup) { // 짧은 쪽 drop (긴 쪽에 정보가 더 있음) const dropIdx = tokenized[i].text.length >= tokenized[j].text.length ? j : i; drop.add(dropIdx); if (dropIdx === i) break; } } } const deduped = tokenized.filter((_, i) => !drop.has(i)); /* 4단계: off-topic 필터 — issue 주제와 한 단어도 안 겹치는 긴 step 은 drop. 회귀: 택배 분실·파손 이슈에 "특히 대금 지급일, 해지 절차, 손해배상 범위를 계약서에 구체적인 숫자와 날짜로 적어 달라" 같은 일반 권고가 끼어드는 케이스. 단, 짧은 마무리 격려체 ("대부분 수정해 줄 거예요" 등) 는 보호. */ const topicSrc = [issue.title || "", issue.suggested_replacement || ""].join(" "); const topicToks = _stepTokens(topicSrc); const onTopic = deduped.filter(({ text, toks }) => { if (topicToks.size === 0) return true; // 비교 기준 없으면 통과 if (_isShortEncouragement(text)) return true; // step 의 의미 토큰이 issue 주제와 한 단어라도 겹치면 keep // (조사 차이 대비 prefix matching — _hasSimilarToken) for (const t of toks) { if (_hasSimilarToken(topicToks, t)) return true; } // 토큰 한 개도 안 겹치고 길이도 충분히 길면 무관 step → drop return text.length < 25; // 짧으면 일단 keep (일반 마무리 가능성) }); return onTopic.map((x) => x.text); })(); return (
{sev.label} · {statusLabel}

{issue.title}

{issue.quote && (
계약서에 적힌 문구 ({issue.location_in_contract || "위치 정보 없음"})
{renderQuoteWithHighlights(issue.quote)}
)} {(() => { const { body, tldr } = splitReasonChunks(issue.reason); if (!body && !tldr) return null; return (
{body && (

{renderQuoteWithHighlights(body)}

)} {tldr && (
한 줄로

{renderQuoteWithHighlights(tldr)}

)}
); })()} {steps.length > 0 && (

이렇게 고쳐 달라고 말해 봐요

{steps.map((s, i) => (
{i + 1}

{renderQuoteWithHighlights(s)}

))}
)} {/* 바로 넣어볼 문구 (suggested_replacement 전용) — 해요체 안내 "말해 봐요" 리스트와 섞이면 톤이 튀어서 의도적으로 분리. 평서 어미 모범조항은 여기 에서 단독 카드로. 복사해서 계약서/특약에 넣을 수 있게 시각적으로 구분 (견적서 같은 quote 박스 톤). */} {issue.suggested_replacement && (
바로 넣어볼 문구

{renderQuoteWithHighlights(issue.suggested_replacement)}

)} {issue.law_reference && (
근거 법령
{issue.law_reference}
)}
); } window.IssueDetail = IssueDetail; Object.assign(window, { IssueDetail });