발행이 SiteOntology 를 부르는데 서버는 따로 띄워야 했다. 실측(2026-09-14): 서버가 없으면 `[seo] SiteOntology 실패 — 키워드 없이 발행: ConnectError` 로 빌드는 성공하고 메타만 빈다 — 화면으로는 안 보이는 종류다. 한 벌로 묶어 "코드는 올라갔는데 서버가 없는" 상태를 없앤다. - ontology/: gitea.o2o.kr/Web4ai/o2o-site-ontology 를 이 레포로 편입(그 원격은 그대로 남는다) - ontology/Dockerfile(신규): 베이스는 node:22-slim. alpine 은 임베딩 런타임(onnxruntime)이 musl 바이너리를 안 줘서 적재가 ERR_DLOPEN_FAILED 로 죽는다 — 빌드는 성공하고 실행에서만 터진다 - docker-compose.yml: ontology · ontology-postgres(pgvector) · ontology-redis 추가. 자체 DB 를 쓰는 이유는 pgvector 확장 때문이다 — web4ai_db 를 남의 서비스 확장에 묶지 않는다 - 임베딩 모델(120MB)은 이미지에 굽지 않고 볼륨(ontology-model)에 남긴다 - 컨테이너끼리는 `http://ontology:3100` 으로 만난다. `.env` 의 127.0.0.1 은 컨테이너 자기 자신이라 안 닿는다 검증: 3개 기동 · 백엔드 컨테이너에서 ontology:3100/demo HTTP 200 · 마이그레이션·시드 완료 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
158 lines
7.9 KiB
Python
158 lines
7.9 KiB
Python
# -*- 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()) + ')')
|