최상단을 프로젝트 단위로 평평하게 둔다 — o2o-negosium 과 같은 규약이고, 이 레포만
다르게 갈 이유가 없다. negodata/{backend,front} 가 프로젝트 안에서 f/b 를 가르는 선례,
lps-admin/ 이 백엔드 없이 프론트만 가진 최상단 폴더의 선례다.
backend/ frontend/{admin,site,shared} → solution/{backend,front,site,shared} + admin/
## 왜
내부 라우트(/local-content, /places/:id/seo)의 이름과 화면 코드가 사장님 번들에
그대로 실려 나가고 있었다. UserRole.DEVELOPER 주석의 "고객사에 존재를 노출하지 않는다"를
번들이 깨고 있었다 — 라우트 가드는 화면을 가리지 번들은 못 가린다.
번들을 갈라 확인했다: 사장님 dist 에서 local-content · /places · SeoAudit 이 전부 0건이다.
그 과정에서 두 곳이 더 새고 있었다.
- AppShell 의 NAV 배열이 내부 메뉴를 하드코딩하고 있었다. 앱을 가른 뒤에도 dist 에
local-content 가 남아서 찾았다. 메뉴는 이제 앱이 prop 으로 들고 온다.
- EditorHeader·BuilderPage·LoginPage 가 /places 로 링크하고 있었다. 그 화면이 admin 으로
나갔으니 사장님 앱에서는 404 다. 링크를 걷어내고 LoginPage 기본 도착지는 '/' 로 바꿨다
(앱마다 홈이 다르고 각 라우터의 '/' 가 이미 그걸 안다).
## admin 에 백엔드를 두지 않았다
내부 화면이 부르는 훅이 전부 router/v1/{place,fact,local,validator} 에 이미 있다.
자체 백엔드를 두면 place·fact·link 를 같은 DB 에 대고 두 번 구현하게 된다.
대가는 solution/backend 가 죽으면 admin 도 멈추는 것 — 내부 도구라 감수한다.
## admin 의 `@` 는 solution/front/src 를 가리킨다
내부 화면이 쓰는 API 클라이언트·UI·수집 배선이 solution 에 한 벌만 있고 그 파일들끼리도
`@/...` 로 서로를 부른다. admin 에서 `@` 를 자기 src 로 잡으면 그 참조가 전부 깨진다
(실측 TS2307 14건). 복제하는 길도 있지만 RecollectPanel 주석이 금지한다 —
"수집 경로를 두 벌 만들면 확정 게이트"가 갈라진다.
admin 자기 파일만 `@admin` 이고, 의존 방향은 admin → solution 한 쪽뿐이다.
admin 이 여는 빌더는 다른 오리진이라 절대 URL + 새 탭이다(admin/src/lib/solutionUrl.ts).
react-router Link 로 두면 admin 안에서 라우트를 찾다 404 다.
## 그 밖
- npm 워크스페이스 루트를 레포 루트로 올렸다(admin 이 solution 밖이라).
- docker-compose 를 255→174줄로 줄이고 admin(:3002) 서비스를 넣었다. ADMIN_BIND 기본값은
127.0.0.1 — 0.0.0.0 으로 열면 앱을 가른 의미가 없다.
- 발행 호스트를 프론트 .env 에 따로 적지 않는다. compose 가 루트의 SITE_PUBLIC_HOST 를
VITE_PUBLISH_HOST 로 흘려보낸다 — 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
- nginx/site.conf 를 git 에서 빼고 .example 만 남겼다(.env·*.toml 과 같은 규약).
compose 가 bind mount 하므로 클론 직후 복사해야 한다 — 없으면 Docker 가 그 자리에
디렉토리를 만들어 nginx 가 설정 없이 뜬다.
- config.test.toml.example 을 추가했다. 없으면 클론한 사람이 pytest 를 아예 못 돌린다
(conftest import 단계에서 죽는다). 외부 API 키는 전부 빈값이다 —
APP_ENV=test 가 .env 를 안 읽는 이유를 여기서 우회하면 안 된다.
- 경로가 한 칸 깊어져 test_schema_ddl(parents[2]→[3]) 과 test_site_theme 을 고쳤다.
검증: front·admin·site 전부 lint 0 / build 0. 백엔드 514 passed.
남은 4건(test_build_publish 3 · test_snapshot 1)은 이 변경 전부터 실패하던 것으로,
손대지 않은 메인 체크아웃에서 같은 4건이 같게 실패하는 것을 확인했다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uYhHQdssRubirPirrdJJC
111 lines
5.7 KiB
Python
111 lines
5.7 KiB
Python
"""원문 텍스트 → 업종 스키마 fact 추출 프롬프트·응답 스키마.
|
|
|
|
무엇을 묻는가 여기
|
|
어떻게 부르는가 services/llm/gemini.py
|
|
답을 믿을 것인가 services/grounding/extract.py evidence 대조
|
|
무엇을 돌려주는가 services/external/gemini_extract.py
|
|
|
|
★ 왜 사이트별 파서 대신 이걸 쓰는가
|
|
사장님 홈페이지는 카페24·아임웹·워드프레스·수제 HTML 이 전부 제각각이고
|
|
JSON-LD 가 있는 곳이 드물다. 도메인마다 파서를 짜는 것은 끝이 없다.
|
|
대신 **원문에서 찾아 오게 하고, 찾았다는 근거를 원문과 대조**한다.
|
|
|
|
★ 이 프롬프트의 유일한 임무는 "옮겨 적기" 다. 생성이 아니다.
|
|
값을 추론·환산·보정하지 못하게 막는 문장이 규칙의 대부분인 이유다.
|
|
실제 안전장치는 프롬프트가 아니라 evidence 대조 코드에 있다(grounding/extract.py).
|
|
"""
|
|
from common.category_schema import get_schema
|
|
from common.enums import PlaceCategory
|
|
|
|
# Gemini responseSchema(OpenAPI subset). ★ evidence 는 required 다 —
|
|
# 근거를 못 적는 값은 애초에 받지 않는다.
|
|
RESPONSE_SCHEMA = {
|
|
"type": "object",
|
|
"properties": {
|
|
"facts": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"key": {"type": "string", "description": "아래 필드 목록의 key 중 하나. 목록에 없는 key 는 절대 쓰지 않는다"},
|
|
"value": {"type": "string", "description": "원문 표기 그대로. bool 필드는 'true' 또는 'false'"},
|
|
"unit_name": {"type": "string", "description": "scope=unit 필드일 때 어느 객실·메뉴·프로그램인지. place 스코프면 빈 문자열"},
|
|
"evidence": {"type": "string", "description": "이 값의 근거가 된 원문 문장을 **글자 그대로** 복사. 요약·수정 금지"},
|
|
},
|
|
"required": ["key", "value", "evidence"],
|
|
},
|
|
}
|
|
},
|
|
"required": ["facts"],
|
|
}
|
|
|
|
_RULES = """규칙
|
|
1. 아래 [필드 목록]에 있는 key 만 쓴다. 목록에 없는 key 는 버린다.
|
|
2. value 는 원문 표기를 **그대로** 옮긴다. 단위 환산·반올림·요약·번역을 하지 않는다.
|
|
- "오후 3시" 를 "15:00" 으로 바꾸지 않는다. 원문이 "오후 3시" 면 "오후 3시" 다.
|
|
3. evidence 에는 그 값이 적힌 **원문 문장을 글자 그대로 복사**한다.
|
|
- 원문에 없는 문장을 쓰면 그 항목은 버려진다.
|
|
- 여러 문장에 걸쳐 있으면 값이 실제로 적힌 한 문장만 고른다.
|
|
4. 원문에 없는 것은 만들지 않는다. 모르면 그 key 를 빼면 된다. 빈 값으로 채우지 않는다.
|
|
- "보통 펜션은 3시 체크인" 같은 일반 상식으로 채우지 않는다.
|
|
5. type=bool 필드는 value 를 "true" 또는 "false" 로만 쓴다.
|
|
- 원문에 "반려동물 동반 불가" 라고 있으면 pet_allowed = "false" 다. 부정 표현을 놓치지 않는다.
|
|
- 원문에 언급이 아예 없으면 그 key 를 **쓰지 않는다**. "없다"고 단정하지 않는다.
|
|
6. type=number 필드는 숫자만 쓴다(단위·쉼표 없이). 원문이 "최대 4인" 이면 "4" 다.
|
|
7. scope=unit 필드는 unit_name 을 반드시 채운다. 같은 객실·메뉴의 항목은 unit_name 을 똑같이 쓴다.
|
|
- unit_name 은 원문에 적힌 이름 그대로. "A동", "커플룸", "김치찌개".
|
|
8. 홍보 문구·후기·다른 업소 이야기는 근거로 쓰지 않는다.
|
|
- "최고의 전망", "인생 맛집" 같은 표현은 사실이 아니다.
|
|
9. 같은 key 가 원문에서 여러 번 다르게 적혀 있으면, 가장 구체적이고 최신인 한 건만 고른다.
|
|
"""
|
|
|
|
|
|
def _field_lines(category: PlaceCategory) -> str:
|
|
"""업종 스키마를 그대로 프롬프트에 편다.
|
|
|
|
★ 여기서 목록을 따로 관리하지 않는다 — resources/*.json 이 유일한 출처다.
|
|
업종 필드가 늘면 프롬프트도 자동으로 같이 는다.
|
|
"""
|
|
schema = get_schema(category)
|
|
lines = []
|
|
for spec in schema.fields.values():
|
|
# ★ allow_llm 필드(소개문 등)는 목록에 넣지 않는다 — LLM 이 **쓰는** 칸이지
|
|
# 원문에서 옮겨 담는 사실이 아니다. 여기에 원문을 채우면 발행본의 소개가
|
|
# 수집 원문으로 덮인다(2026-08-31 사고, tests/test_collector.py 참고).
|
|
if spec.allow_llm:
|
|
continue
|
|
bits = [f"- {spec.key} ({spec.label})", f"type={spec.type}", f"scope={spec.scope}"]
|
|
if spec.unit:
|
|
bits.append(f"단위={spec.unit}")
|
|
if spec.critical:
|
|
# ★ 틀리면 예약 클레임이 나는 항목. 애매하면 비우라고 명시한다.
|
|
bits.append("※중요-확실할 때만")
|
|
lines.append(" · ".join(bits))
|
|
return "\n".join(lines)
|
|
|
|
|
|
def build_prompt(place_name: str, category: PlaceCategory, source_text: str, *, max_chars: int = 30000) -> str:
|
|
"""추출 프롬프트.
|
|
|
|
source_text 는 앞에서 잘라 넣는다 — 사장님 홈페이지 한 페이지 본문은 대개 이 안에 들어오고,
|
|
넘치면 뒤쪽은 대개 푸터·저작권·다른 페이지 링크라 fact 가 거의 없다.
|
|
"""
|
|
schema = get_schema(category)
|
|
text = (source_text or "").strip()[:max_chars]
|
|
|
|
return f"""너는 사업장 정보를 **원문에서 찾아 옮겨 적는** 추출기다. 글을 쓰는 것이 아니다.
|
|
|
|
[사업장] {place_name} (업종: {schema.label})
|
|
|
|
{_RULES}
|
|
|
|
[필드 목록]
|
|
{_field_lines(category)}
|
|
|
|
[원문]
|
|
\"\"\"
|
|
{text}
|
|
\"\"\"
|
|
|
|
원문에서 찾은 항목만 facts 배열에 담아라. 찾은 게 없으면 빈 배열을 반환한다."""
|