/* ============================================================================ * 서명전에 — iOS Document Preview (fullscreen reader) * ========================================================================== */ function PreviewScreen({ scenario = "jeonse", analysis = null, onBack, onOpenIssue, focusIssueId }) { const isReal = !!analysis; const scenarioData = window.SCENARIOS[scenario] || window.SCENARIOS.jeonse; /* 실제 분석 시: extracted_text + evidence_quote → 문단 + 하이라이트로 재구성 */ const realData = useMemo(() => { if (!isReal) return null; /* 개인정보 (PII) 섹션 truncation — 계약서 끝의 "임대인 주소 / 주민등록번호 / 전화 / 성명 / 중개업자 사무소" 같은 서명란은 분석 결과와 무관할 뿐 아니라 화면에 그대로 노출되면 개인정보 보호 측면에서 위험. PII 트리거가 나타나면 그 직전까지만 잘라낸다. 트리거: 주민등록번호 (가장 확실한 PII 키워드 — 본문 절대 등장 X) + 라벨 + 콜론 패턴 ("임대인 주 소 :", "사무소 소재지 :" 등). newline 구분 없이 text 어디든 등장하면 trigger — docx 파싱이 한 줄로 묶어 출력하는 케이스 (single line 으로 보관한다 → 임대인 주 소 → ... 이 다 이어진 경우) 도 잡기 위함. 라벨 + 콜론 강제로 본문 prose 의 단순 단어 언급은 제외. */ const _PII_TRIGGER_RE = /(?:주민등록번호|임대인\s*주\s*소|임차인\s*주\s*소|개업공인중개사\s*확인|사무소\s*소재지|소속공인중개사|중개업자\s*사무소)\s*[::]/; let text = (analysis.extracted_text || analysis.text_preview || "").trim(); const piiMatch = text.match(_PII_TRIGGER_RE); if (piiMatch && typeof piiMatch.index === "number" && piiMatch.index > 0) { text = text.slice(0, piiMatch.index).trim(); } const issues = (analysis.issues || []).map((iss, i) => ({ id: `srv-i${i}`, ...iss })); /* 문단 분할 — docx 파서 (parsers.py `_extract_docx`) 가 paragraph 단위로 단일 `\n` 으로 join 하기 때문에, double `\n\n` section split 만으로는 모든 줄이 한 덩어리로 묶임. 한국 계약서의 자연 구조 (제N조 / 금액 라벨 / 부동산 표시 라벨) 에 맞춰 추가 split splitter 들을 묶어서 파이프라인: (1) double \n\n 으로 section (2) section 내부에서 split 트리거 (각 라인 시작이 다음 중 하나): - "1. " "2. " 등 번호 항목 - "제 N 조" 조항 마커 - "보증금:" "계약금:" "중도금:" "잔금:" "차임:" "월세:" "관리비:" "임대차 기간:" 같은 금액·기간 라벨 - "소재지:" "토지:" "건물:" "임대할부분:" "임대부분:" 같은 부동산 표시 라벨 - "특약사항" 섹션 헤더 (3) 최종 single \n 은 단락 내 줄바꿈 → 공백 회귀: "월세 계약서" 같은 prose 의 "월세" 가 split 트리거되지 않게 label 패턴은 반드시 line-start (\n 이후) + 라벨 + 콜론 형태로 한정. */ const _SECTION_SPLIT_RE = new RegExp( "\\n(?=" + // 번호 항목 "\\s*\\d+\\.\\s" + // 제 N 조 "|\\s*제\\s*\\d+\\s*조" + // 금액 / 기간 라벨 + 콜론 (공백 허용) "|\\s*(?:보\\s*증\\s*금|계\\s*약\\s*금|중\\s*도\\s*금|잔\\s*금|차\\s*임|월\\s*세|월\\s*임대료|임대료|관\\s*리\\s*비|임대차\\s*기간|계약\\s*기간|존속\\s*기간)\\s*[::]" + // 부동산 표시 라벨 + 콜론 "|\\s*(?:소\\s*재\\s*지|토\\s*지|건\\s*물|임대할부분|임대부분|임대\\s*부분|면적|지목)\\s*[::]" + // 특약사항 헤더 (line-start 면 다음에 \n 이나 EOL 이 와도 split 트리거) "|\\s*특약사항(?=\\s|$)" + ")", "g" ); const rawParagraphs = text ? text // (1) 빈 줄 기준 section split .split(/\n\s*\n+/) // (2) section 내부에서 다양한 split trigger 적용 .flatMap((section) => section.split(_SECTION_SPLIT_RE)) // (3) 단일 \n 잔여는 단락 내 줄바꿈 → 공백 .map((p, idx) => ({ id: `p${idx}`, text: p.trim().replace(/\s*\n\s*/g, " ") })) .filter((p) => p.text) : []; /* ── 매칭 전략 ─────────────────────────────────────────────────────── 이전 버전은 evidence_quote 의 [[K]] 마커를 단순 제거하고 "clean 텍스트 전체" 를 `indexOf` 로 본문에서 찾았음. 문제: LLM 이 마커 바깥 문맥에 본문과 한 끗 다른 공백/구두점 (예: "임차인은, " vs "임차인은") 을 섞어 보내면 전체가 통째로 불일치로 떨어져 하이라이트 자체가 사라짐. 특약 여러 개(3·4·5·6·7번) 가 다 위험인데 6번 하나만 마커 내외가 모두 본문과 일치해서 유일하게 살아남는 회귀가 그 원인. 새 전략: ① [[K]]...[[/K]] 마커 안 구절을 각각 꺼내 그걸 본문에서 찾는다. 마커는 백엔드 `ensure_markers_in()` 이 모든 evidence_quote 에 보장하므로 사실상 모든 이슈가 이 경로로 하이라이트된다. ② 마커 구절이 본문에 없거나 마커가 아예 없으면 evidence_quote 전체 (마커 제거) 매칭으로 폴백. ③ 공백이 달라 ①·② 가 실패하면 공백만 지운 normalized 형태로 재탐색, 매칭된 실제 substring 을 본문에서 복원해 돌려준다. ──────────────────────────────────────────────────────────────── */ const _stripMarkers = (s) => (s || "").replace(/\[\[K\]\]([^\[]*?)\[\[\/K\]\]/g, "$1"); const _extractMarked = (s) => { const out = []; const re = /\[\[K\]\]([^\[]*?)\[\[\/K\]\]/g; let m; while ((m = re.exec(s || ""))) { if (m[1] && m[1].trim()) out.push(m[1]); } return out; }; /* 공백 무시 matching — haystack 안에서 needle 을 공백 차이까지 허용해 찾고, 매칭된 원본 substring 을 돌려준다. 없으면 null. 탐색은 해당 paragraph 단위라 크기가 크지 않아 O(n·slack) 로 충분. */ const _findLoose = (haystack, needle) => { if (!needle) return null; if (haystack.indexOf(needle) >= 0) return needle; const ns = (s) => s.replace(/\s+/g, ""); const needleN = ns(needle); if (!needleN) return null; if (ns(haystack).indexOf(needleN) < 0) return null; const maxSlack = Math.max(4, Math.floor(needle.length * 0.25)); for (let start = 0; start < haystack.length; start++) { const maxEnd = Math.min(haystack.length, start + needle.length + maxSlack); for (let end = start + needleN.length; end <= maxEnd; end++) { if (ns(haystack.slice(start, end)) === needleN) { return haystack.slice(start, end); } } } return null; }; const paraMarks = rawParagraphs.map((p) => { const marks = []; const seen = new Set(); // 같은 이슈의 같은 구절이 중복 추가되지 않도록 const pushMark = (iss, found) => { const key = `${iss.id}::${found}`; if (seen.has(key)) return; seen.add(key); marks.push({ from: found, issue_id: iss.id, severity: iss.severity, issue: iss, }); }; issues.forEach((iss) => { const eq = iss.evidence_quote || ""; if (!eq) return; // ① 마커 안 구절 우선 const markedPhrases = _extractMarked(eq); let hitByMarker = false; markedPhrases.forEach((phrase) => { const found = _findLoose(p.text, phrase); if (found) { pushMark(iss, found); hitByMarker = true; } }); if (hitByMarker) return; // ② 마커 안 구절을 못 찾았으면 evidence_quote 전체(마커 제거) 로 폴백 const clean = _stripMarkers(eq); if (!clean) return; const foundClean = _findLoose(p.text, clean); if (foundClean) pushMark(iss, foundClean); }); return { ...p, marks }; }); const paragraphs = paraMarks; return { filename: analysis.filename || "올린 계약서", paragraphs: paraMarks, issues, }; }, [isReal, analysis]); const data = isReal ? realData : scenarioData; const [scrolled, setScrolled] = useState(false); const scrollRef = useRef(null); const issuesCount = (data?.issues || []).filter(i => i.severity !== "low").length; /* Scroll to focus issue when provided */ useEffect(() => { if (!focusIssueId || !scrollRef.current) return; const el = scrollRef.current.querySelector(`mark[data-id="${focusIssueId}"]`); if (el) { const container = scrollRef.current; const top = el.offsetTop - 80; container.scrollTo({ top, behavior: "smooth" }); } }, [focusIssueId, isReal]); /* Render line with marks interpolated (mock scenarios OR real evidence_quote) */ const renderLine = (line) => { if (!line.marks || line.marks.length === 0) return line.text; let text = line.text; const parts = []; let cursor = 0; const marks = [...line.marks] .map((m) => ({ ...m, idx: text.indexOf(m.from) })) .filter((m) => m.idx >= 0) .sort((a, b) => a.idx - b.idx); marks.forEach((m, i) => { if (m.idx > cursor) parts.push(text.slice(cursor, m.idx)); const issue = m.issue || (data.issues || []).find((iss) => iss.id === m.issue_id); parts.push( issue && onOpenIssue(issue)} style={{ cursor: "pointer" }} > {m.from} , ); cursor = m.idx + m.from.length; }); if (cursor < text.length) parts.push(text.slice(cursor)); return parts; }; return (
읽어낸 글자가 없어요. 사진 화질이 낮거나 OCR이 제대로 되지 않았을 수 있으니 밝은 곳에서 다시 찍어 주세요.
) : ( (data.paragraphs || []).map((p) => ({renderLine(p)}
)) )} > ) : ( (data.source_lines || []).map((line, i) => { if (i === 0) { return{renderLine(line)}
; }) )}