o2o-site-AEO/ontology/scripts/export-db-xlsx.py
Mina Choi 11d30bb3d1 [chore] solution,admin,ontology: 코드 주석을 한 줄로 — 히스토리 주석 삭제
여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다.

- ts·tsx·js·mjs·css·py 478개: 여러 줄 주석은 첫 문장 한 줄로, 과거형·날짜 문장은 삭제
- 주석 위치는 TypeScript 파서·파이썬 tokenize/ast 로 찾는다 — 문자열 안의 # · /* 는 건드리지 않는다
- eslint·ts·noqa·type: ignore 같은 지시 주석은 그대로 둔다

파이썬 275개 정리 전후 AST 동일, TS 298개 주석 뺀 토큰 동일(빈 JSX 주석 10곳만 차이).
site·frontend·admin tsc, site vitest 105 passed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:05:19 +09:00

155 lines
7.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""벡터 DB 에 실제로 적재된 내용을 그대로 엑셀로 뽑는다 (배포용)."""
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()) + ')')