# -*- coding: utf-8 -*- """벡터 DB 에 실제로 적재된 내용을 그대로 엑셀로 뽑는다 (배포용). python3 scripts/export-db-xlsx.py 데이터셋 JSON 이 아니라 DB 가 기준이다. 임베딩은 엑셀에 담지 않는다 — 384개 float × 7천 행이라 의미가 없고, 같은 모델로 재생성하면 동일하게 복원된다.""" import csv, io, subprocess, collections from openpyxl import Workbook from openpyxl.styles import Font, PatternFill, Alignment, Border, Side from openpyxl.utils import get_column_letter OUT = 'data/배포용_키워드_DB덤프.xlsx' CONT = 'ontology-postgres' DB = ['psql', '-U', 'ontology', '-d', 'ontology', '-t', '-A', '--csv', '-c'] INK = '1F2A2B' HEAD = PatternFill('solid', fgColor='0D6A60') THIN = Side(style='thin', color='D5DCDB') BOX = Border(left=THIN, right=THIN, top=THIN, bottom=THIN) SRC_FILL = {'dataset': 'DFF0EC', 'nationwide': 'FFFFFF', 'manual': 'F6EAD2'} def query(sql: str): out = subprocess.run(['docker', 'exec', '-i', CONT, *DB, sql], capture_output=True, text=True, check=True).stdout return list(csv.reader(io.StringIO(out))) def sheet(wb, title, header, rows, widths_, fill_col=None, first=False): ws = wb.active if first else wb.create_sheet(title) if first: ws.title = title ws.append(header) for r in rows: ws.append(r) for row in ws.iter_rows(min_row=2, max_row=ws.max_row, max_col=len(header)): fill = None if fill_col is not None: fill = PatternFill('solid', fgColor=SRC_FILL.get(row[fill_col].value, 'FFFFFF')) for c in row: c.font = Font(size=10, color=INK); c.border = BOX if fill: c.fill = fill for c in range(1, len(header) + 1): cell = ws.cell(row=1, column=c) cell.fill = HEAD; cell.font = Font(bold=True, color='FFFFFF', size=10) cell.alignment = Alignment(horizontal='center', vertical='center') cell.border = BOX ws.row_dimensions[1].height = 22 ws.freeze_panes = 'A2' ws.auto_filter.ref = f'A1:{get_column_letter(len(header))}{ws.max_row}' for i, w in enumerate(widths_, start=1): ws.column_dimensions[get_column_letter(i)].width = w return ws wb = Workbook() # 1) 키워드 — DB 전체 kw = query(""" SELECT k.id, k.canonical, k.normalized, k.locale, array_to_string(k.aliases, ' | ') AS aliases, k.intent, k.kind, COALESCE(k.category,'') AS category, k.source, COALESCE(k.industry_id,'') , COALESCE(k.region_id,''), COALESCE(r.name,''), COALESCE(sido.name,''), k.usage_count, (k.embedding IS NOT NULL) AS has_embedding, to_char(k.updated_at,'YYYY-MM-DD HH24:MI') FROM keyword k LEFT JOIN region r ON r.id = k.region_id LEFT JOIN region sido ON sido.id = regexp_replace(k.region_id, '\\.[^.]+$', '') ORDER BY k.source, sido.name NULLS FIRST, r.name NULLS FIRST, k.canonical """) sheet(wb, '키워드', ['id', '키워드', '정규화', 'locale', '흡수된 표기(alias)', '의도', '종류', '카테고리', '출처', '업종ID', '지역ID', '지역', '시도', '사용업체수', '임베딩', '갱신일시'], kw, [38, 30, 26, 8, 30, 14, 8, 10, 12, 14, 24, 12, 8, 10, 9, 17], fill_col=8, first=True) # 2~5) 마스터 sheet(wb, '지역', ['지역ID', '경로(ltree)', '지역명'], query("SELECT id, path::text, name FROM region ORDER BY path"), [26, 26, 16]) sheet(wb, '업종', ['업종ID', '경로(ltree)', '업종명'], query("SELECT id, path::text, name FROM industry ORDER BY path"), [22, 22, 16]) sheet(wb, '업체', ['외부ID', '상호', '업종', '지역', '소개', '사이트', '프로필(JSON)'], query("""SELECT m.external_id, m.name, COALESCE(i.name,''), COALESCE(r.name,''), m.description, COALESCE(m.site_url,''), m.profile::text FROM merchant m LEFT JOIN industry i ON i.id=m.industry_id LEFT JOIN region r ON r.id=m.region_id ORDER BY m.external_id"""), [14, 18, 12, 10, 50, 34, 70]) sheet(wb, 'QA(AEO)', ['업체', '질문', '답변', '상태'], query("""SELECT m.name, q.question, q.answer, q.status::text FROM qa_pair q JOIN merchant m ON m.id=q.merchant_id ORDER BY m.name, q.created_at"""), [16, 44, 70, 10]) # 5-b) 업체↔키워드 연결 sheet(wb, '업체키워드', ['업체', '키워드', '관련도', '상태', '출처', '노출수', '클릭수', 'CTR', '근거'], query("""SELECT m.name, k.canonical, round(mk.relevance::numeric,2), mk.status::text, mk.source, mk.impressions, mk.clicks, round(mk.ctr::numeric,4), COALESCE(mk.rationale,'') FROM merchant_keyword mk JOIN merchant m ON m.id=mk.merchant_id JOIN keyword k ON k.id=mk.keyword_id ORDER BY m.name, mk.relevance DESC"""), [16, 30, 9, 10, 10, 10, 9, 9, 28]) # 5-c) 생성 이력 (감사 로그) sheet(wb, '생성이력', ['업체', 'provider', 'model', '프롬프트버전', '트리거', '상태', '통계', '시작', '종료'], query("""SELECT COALESCE(m.name,''), g.provider, g.model, g.prompt_version, g.trigger, g.status, g.stats::text, to_char(g.started_at,'YYYY-MM-DD HH24:MI'), COALESCE(to_char(g.finished_at,'YYYY-MM-DD HH24:MI'),'') FROM generation_run g LEFT JOIN merchant m ON m.id=g.merchant_id ORDER BY g.started_at DESC"""), [16, 10, 20, 14, 12, 10, 60, 17, 17]) # 6) 배포 가이드 counts = collections.Counter(r[8] for r in kw) guide = [ ('무엇이 들어있나', ''), ('', f"벡터 DB(keyword 테이블)에 실제 적재된 {len(kw):,}건 전부. 데이터셋 JSON 이 아니라 DB 가 기준이다."), ('', '출처별: ' + ' · '.join(f'{k} {v:,}' for k, v in counts.most_common())), ('', 'dataset = 군산 상세(매칭 엔진 개발용) / nationwide = 전국 54개 지역'), ('', 'DB 의 7개 테이블을 모두 담았다: keyword / region / industry / merchant /'), ('', 'merchant_keyword / qa_pair / generation_run.'), ('', ''), ('임베딩은 왜 없나', ''), ('', '384개 float × 7천 행이라 엑셀에 담을 수 없고 담아도 못 읽는다.'), ('', '[임베딩] 열은 DB 에 벡터가 있는지만 표시한다.'), ('', '같은 모델(Xenova/multilingual-e5-small)로 다시 만들면 동일한 값이 나오므로'), ('', '텍스트만 있으면 복원된다.'), ('', ''), ('배포 방법 2가지', ''), ('A. pg_dump (권장)', '임베딩 포함 그대로 복원. 재임베딩 불필요.'), ('', ' npm run db:dump → data/ontology-dump.sql.gz'), ('', ' gunzip -c data/ontology-dump.sql.gz | psql $TARGET_URL'), ('B. 재적재', '텍스트에서 임베딩을 다시 만든다. 최초 1회 모델 다운로드(약 50초) + 임베딩 약 15초.'), ('', ' npm run db:migrate && npm run db:seed'), ('', ' npm run dataset:ingest && npm run dataset:ingest-nationwide'), ('', ''), ('⚠ 검색량은 아직 비어있다', ''), ('', '이 키워드는 검색 패턴 생성물이지 실제 검색 데이터가 아니다.'), ('', '네이버 검색광고 키워드도구로 월간검색수를 채우고 월 10 미만을 걷어내야'), ('', '실서비스에 쓸 수 있다. (npm run dataset:import-related 로 CSV 병합)'), ] ws = sheet(wb, '배포가이드', ['항목', '내용'], guide, [22, 100]) for row in ws.iter_rows(min_row=2, max_row=ws.max_row, max_col=2): if row[0].value and not row[1].value: row[0].font = Font(size=10, bold=True, color='0D6A60') row[1].alignment = Alignment(wrap_text=True, vertical='center') wb.save(OUT) print(f'✅ {OUT}') print(f' 시트: ' + ', '.join(s.title for s in wb.worksheets)) print(f' 키워드 {len(kw):,}행 (' + ', '.join(f'{k} {v:,}' for k, v in counts.most_common()) + ')')