[fix] solution/backend: 옛 테이블 이름 잔재로 빌더가 통째로 안 돌던 것
웹빌더가 세 자리에서 연달아 죽었다 — 가게 등록 · 수집 시작 · 수집 완료. 전부 같은 뿌리다: DB 구조 재편이 테이블 이름을 옮기면서 **참조 세 종류 중 일부만** 따라갔다. - **생성자 12군데** (`place_links(...)` → `place_channels(...)`) import 와 `DBType()` 은 고쳤는데 생성자를 빠뜨렸다. 클래스가 없어도 import 는 통과하므로 기동은 정상이고, 그 줄이 실제로 실행되는 순간에만 터진다. - **raw SQL 12군데** (`job.jobs` → `jobs`) 잡 큐만 raw SQL 이라 ORM 이름 변경에 안 딸려 왔다. 큐가 안 도니 수집·비전·소개문·빌드· 지역데이터가 하나도 못 들어간다. 화면에는 "버튼만 안 먹는" 것으로 보였다. - **같은 이름의 속성 5군데** (`source.place_facts` → `source.facts` 등) 이름만 보고 일괄 치환해 테이블과 무관한 자리까지 바뀌었다. `RawSource` 는 수집기 결과 객체지 테이블이 아니다. - **뗀 표를 계속 부르던 5군데** (`ai_check_results`) 한 번도 쓰지 않아 마이그레이션이 뗀 표다. 부르면 SEO 진단이 통째로 죽는다. ★ 하나씩 터질 때마다 고치다가 멈추고 정적 검사로 남은 것을 한 번에 셌다 — pyflakes 가 19건을 짚었다. 이 종류는 import 도 타입검사도 안 잡는다. 테이블 이름을 옮긴 뒤에는 `python -m pyflakes services/ crud/ router/ worker/ common/ | grep "undefined name"` 을 돌린다. 검증: 직접 수집 실행(스테이,머뭄) — 잡 DONE · 재시도 0 · fact 2 · 사진 10 · 채널 2 저장. 정의 안 된 이름 0건. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c4af53613e
commit
387783b766
@ -259,7 +259,7 @@ FACT_STATUS_TRANSITIONS = {
|
||||
|
||||
|
||||
class LinkChannel(CodeEnum):
|
||||
"""place_links.channel 코드값. Perplexity 가 발견하는 채널 종류."""
|
||||
"""place_channels.channel 코드값. Perplexity 가 발견하는 채널 종류."""
|
||||
|
||||
YANOLJA = 1 # 야놀자
|
||||
GOODCHOICE = 2 # 여기어때
|
||||
|
||||
@ -45,7 +45,7 @@ class JobQueue:
|
||||
) -> str | None:
|
||||
"""잡 적재. dedupe_key 가 활성(PENDING/RUNNING) 중복이면 삽입 없이 None 반환."""
|
||||
sql = text("""
|
||||
INSERT INTO job.jobs (job_type, priority, payload, dedupe_key, max_attempts)
|
||||
INSERT INTO jobs (job_type, priority, payload, dedupe_key, max_attempts)
|
||||
VALUES (:t, :p, CAST(:payload AS jsonb), :dk, :ma)
|
||||
ON CONFLICT (dedupe_key) WHERE status IN (1, 2) AND dedupe_key IS NOT NULL
|
||||
DO NOTHING
|
||||
@ -69,7 +69,7 @@ class JobQueue:
|
||||
"""대기 잡 1건을 원자적으로 점유. 없으면 None.
|
||||
FOR UPDATE SKIP LOCKED 로 잠근 행을 같은 UPDATE 에서 RUNNING 으로 전이 → 이중 할당 불가."""
|
||||
sql = text("""
|
||||
UPDATE job.jobs SET
|
||||
UPDATE jobs SET
|
||||
status = 2,
|
||||
worker_id = :wid,
|
||||
lease_until = now() + make_interval(secs => :lease),
|
||||
@ -77,7 +77,7 @@ class JobQueue:
|
||||
attempts = attempts + 1,
|
||||
updated_at = now()
|
||||
WHERE job_id = (
|
||||
SELECT job_id FROM job.jobs
|
||||
SELECT job_id FROM jobs
|
||||
WHERE status = 1 AND run_after <= now()
|
||||
ORDER BY priority ASC, created_at ASC
|
||||
FOR UPDATE SKIP LOCKED
|
||||
@ -101,7 +101,7 @@ class JobQueue:
|
||||
# ---- 완료/실패 (소유권 가드) ----
|
||||
async def complete(self, job_id: str, worker_id: str, result: dict | None = None) -> bool:
|
||||
sql = text("""
|
||||
UPDATE job.jobs SET status = 3, result = CAST(:result AS jsonb),
|
||||
UPDATE jobs SET status = 3, result = CAST(:result AS jsonb),
|
||||
lease_until = NULL, worker_id = NULL, updated_at = now()
|
||||
WHERE job_id = CAST(:id AS uuid) AND status = 2 AND worker_id = :wid
|
||||
RETURNING job_id
|
||||
@ -120,7 +120,7 @@ class JobQueue:
|
||||
"""실패 처리. 시도 남으면 PENDING(run_after=백오프)으로 재큐, 소진되면 DEAD(dead-letter).
|
||||
전이 후 status(JobStatus 값)를 반환. 소유 불일치면 None."""
|
||||
sql = text("""
|
||||
UPDATE job.jobs SET
|
||||
UPDATE jobs SET
|
||||
status = CASE WHEN attempts >= max_attempts THEN 4 ELSE 1 END,
|
||||
run_after = CASE WHEN attempts >= max_attempts THEN run_after
|
||||
ELSE now() + make_interval(secs => :backoff) END,
|
||||
@ -143,7 +143,7 @@ class JobQueue:
|
||||
# ---- lease 갱신(heartbeat) / 회수(reaper) ----
|
||||
async def renew_lease(self, job_id: str, worker_id: str, lease_sec: int = 120) -> bool:
|
||||
sql = text("""
|
||||
UPDATE job.jobs SET lease_until = now() + make_interval(secs => :lease), updated_at = now()
|
||||
UPDATE jobs SET lease_until = now() + make_interval(secs => :lease), updated_at = now()
|
||||
WHERE job_id = CAST(:id AS uuid) AND worker_id = :wid AND status = 2
|
||||
RETURNING job_id
|
||||
""")
|
||||
@ -158,7 +158,7 @@ class JobQueue:
|
||||
"""만료된 lease(워커 사망 등)의 RUNNING 잡을 회수. 시도 남으면 즉시 재큐, 소진되면 DEAD.
|
||||
회수된 job_id 목록 반환."""
|
||||
sql = text("""
|
||||
UPDATE job.jobs SET
|
||||
UPDATE jobs SET
|
||||
status = CASE WHEN attempts >= max_attempts THEN 4 ELSE 1 END,
|
||||
run_after = now(),
|
||||
last_error = COALESCE(last_error, '') || ' [lease-expired reclaim]',
|
||||
@ -181,7 +181,7 @@ class JobQueue:
|
||||
sql = text("""
|
||||
SELECT job_id, job_type, status, priority, attempts, max_attempts,
|
||||
payload, result, last_error, run_after, run_started_at, created_at, updated_at
|
||||
FROM job.jobs WHERE job_id = CAST(:id AS uuid)
|
||||
FROM jobs WHERE job_id = CAST(:id AS uuid)
|
||||
""")
|
||||
|
||||
async def run(s):
|
||||
@ -201,7 +201,7 @@ class JobQueue:
|
||||
"""dedupe_key 로 활성(PENDING/RUNNING) 잡을 찾는다.
|
||||
enqueue 가 중복으로 None 을 돌려줬을 때, 이미 돌고 있는 잡의 id 를 알려주기 위함."""
|
||||
sql = text("""
|
||||
SELECT job_id, job_type, status FROM job.jobs
|
||||
SELECT job_id, job_type, status FROM jobs
|
||||
WHERE dedupe_key = :dk AND status IN (1, 2)
|
||||
LIMIT 1
|
||||
""")
|
||||
@ -220,7 +220,7 @@ class JobQueue:
|
||||
async def counts(self) -> dict[str, int]:
|
||||
"""상태별 잡 개수."""
|
||||
async def run(s):
|
||||
rows = (await s.execute(text("SELECT status, count(*) FROM job.jobs GROUP BY status"))).all()
|
||||
rows = (await s.execute(text("SELECT status, count(*) FROM jobs GROUP BY status"))).all()
|
||||
by_val = {int(st): int(c) for st, c in rows}
|
||||
return {js.name: by_val.get(js.value, 0) for js in JobStatus}
|
||||
|
||||
@ -246,7 +246,7 @@ class JobQueue:
|
||||
COALESCE(EXTRACT(EPOCH FROM (now() - min(created_at) FILTER (WHERE status = 1)))::int, 0) AS oldest_pending_sec,
|
||||
count(*) FILTER (WHERE last_error LIKE 'JobDeadlineExceeded%'
|
||||
AND updated_at > now() - interval '1 hour') AS deadline_1h
|
||||
FROM job.jobs
|
||||
FROM jobs
|
||||
""")
|
||||
|
||||
async def run(s):
|
||||
@ -258,7 +258,7 @@ class JobQueue:
|
||||
"""DEAD 잡 재큐(관리자 액션): attempts 리셋 + PENDING 전이 + 워커 깨움.
|
||||
DEAD 가 아니거나 없으면 None. 같은 dedupe_key 의 활성 잡이 있으면 부분 유니크 위반."""
|
||||
sql = text("""
|
||||
UPDATE job.jobs SET status = 1, attempts = 0, run_after = now(),
|
||||
UPDATE jobs SET status = 1, attempts = 0, run_after = now(),
|
||||
lease_until = NULL, worker_id = NULL, run_started_at = NULL,
|
||||
last_error = NULL, updated_at = now()
|
||||
WHERE job_id = CAST(:jid AS uuid) AND status = 4
|
||||
|
||||
@ -30,8 +30,8 @@ class CollectAborted(RuntimeError):
|
||||
async def _add_link(place_id: str, channel, url: str, title: str, discovered_by, raw=None) -> bool:
|
||||
"""링크 한 건 적재. 이미 있으면 False(유니크 충돌은 재수집의 정상 경로다)."""
|
||||
err = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[place_links.DBType()],
|
||||
[lambda s: _place_crud.add_link(s, place_links(
|
||||
[place_channels.DBType()],
|
||||
[lambda s: _place_crud.add_link(s, place_channels(
|
||||
place_id=uuid.UUID(place_id), channel=channel.value, url=url, title=title,
|
||||
discovered_by=discovered_by.value, discovered_at=GTime.UTC(), raw=raw,
|
||||
))],
|
||||
@ -82,7 +82,7 @@ async def discover_naver_place(place, place_id: str) -> str:
|
||||
# 근거가 사람 확인과 같은 수준이므로 클릭을 한 번 더 받는 것은 이득 없이 막기만 한다 —
|
||||
# 실제로 이 클릭 때문에 힐튼·도플로가 "수집했는데 0건"으로 끝났다.
|
||||
err, _rows = await DB_SESSION_MNG.execute_lambda_claim(
|
||||
place_links.DBType(),
|
||||
place_channels.DBType(),
|
||||
lambda s: _place_crud.confirm_link_by_url(s, uuid.UUID(place_id), url, place.verified_by, GTime.UTC()),
|
||||
)
|
||||
LOG.i(f"[collect] 네이버 플레이스 링크 {'등록·확정' if added else '확정'} — place {place_id_naver}")
|
||||
@ -128,7 +128,7 @@ async def discover_tour_api(place, place_id: str) -> str:
|
||||
place_id, LinkChannel.ETC, url, f"{place.name} 한국관광공사 TourAPI", SourceType.API,
|
||||
)
|
||||
err, _rows = await DB_SESSION_MNG.execute_lambda_claim(
|
||||
place_links.DBType(),
|
||||
place_channels.DBType(),
|
||||
lambda s: _place_crud.confirm_link_by_url(s, uuid.UUID(place_id), url, place.verified_by, GTime.UTC()),
|
||||
)
|
||||
LOG.i(f"[collect] TourAPI 링크 {'등록·확정' if added else '확정'} — contentId {content_id}")
|
||||
@ -136,7 +136,7 @@ async def discover_tour_api(place, place_id: str) -> str:
|
||||
|
||||
|
||||
async def discover_links(place, place_id: str, *, include_perplexity: bool = False) -> dict:
|
||||
"""채널 URL 을 찾아 place_links 에 적재한다(미확정 상태).
|
||||
"""채널 URL 을 찾아 place_channels 에 적재한다(미확정 상태).
|
||||
|
||||
★ 순서가 곧 신뢰도다. 지금 자동 발견 경로는 **네이버 플레이스 직접 해석 하나뿐**이고,
|
||||
Perplexity 는 사용자가 추가 채널 탐색 옵션을 고른 회차에만 실행한다.
|
||||
@ -198,7 +198,7 @@ async def discover_links(place, place_id: str, *, include_perplexity: bool = Fal
|
||||
stat["filtered_out"] = found.reason_counts()
|
||||
now = GTime.UTC()
|
||||
for link in found.links:
|
||||
row = place_links(
|
||||
row = place_channels(
|
||||
place_id=uuid.UUID(place_id),
|
||||
channel=link.channel.value,
|
||||
url=link.url,
|
||||
@ -208,7 +208,7 @@ async def discover_links(place, place_id: str, *, include_perplexity: bool = Fal
|
||||
raw=found.raw, # 본문 + search_results 원문 — 환각 추적용
|
||||
)
|
||||
err = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[place_links.DBType()],
|
||||
[place_channels.DBType()],
|
||||
[lambda s, r=row: _place_crud.add_link(s, r)],
|
||||
)
|
||||
if err == ErrorType.SUCCESS:
|
||||
@ -231,7 +231,7 @@ async def confirm_targets(place, place_id: str, only_link_ids: list[str] | None)
|
||||
"""
|
||||
stat = {"confirmed": 0, "already": 0, "unsupported": 0}
|
||||
err, links = await DB_SESSION_MNG.execute_lambda(
|
||||
place_links.DBType(),
|
||||
place_channels.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: _place_crud.list_links(s, uuid.UUID(place_id), False),
|
||||
)
|
||||
@ -253,7 +253,7 @@ async def confirm_targets(place, place_id: str, only_link_ids: list[str] | None)
|
||||
targets.append(link)
|
||||
continue
|
||||
_e, rowcount = await DB_SESSION_MNG.execute_lambda_claim(
|
||||
place_links.DBType(),
|
||||
place_channels.DBType(),
|
||||
lambda s, l=link: _place_crud.confirm_link(s, uuid.UUID(place_id), l.link_id, place.verified_by, now),
|
||||
)
|
||||
if rowcount:
|
||||
@ -322,7 +322,7 @@ async def ensure_units(place_id: str, sources: list) -> dict:
|
||||
return {}
|
||||
|
||||
err, rows = await DB_SESSION_MNG.execute_lambda(
|
||||
units.DBType(),
|
||||
place_units.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: _place_crud.list_units(s, uuid.UUID(place_id)),
|
||||
)
|
||||
@ -332,9 +332,9 @@ async def ensure_units(place_id: str, sources: list) -> dict:
|
||||
for order, name in enumerate(names):
|
||||
if name in existing:
|
||||
continue
|
||||
row = units(place_id=uuid.UUID(place_id), name=name, sort_order=order)
|
||||
row = place_units(place_id=uuid.UUID(place_id), name=name, sort_order=order)
|
||||
run_err = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[units.DBType()],
|
||||
[place_units.DBType()],
|
||||
[lambda s, r=row: _place_crud.add_unit(s, r)],
|
||||
)
|
||||
if run_err == ErrorType.SUCCESS:
|
||||
@ -356,7 +356,7 @@ async def store_facts(user_info, place_id: str, sources: list, unit_map: dict) -
|
||||
stat = {"stored": 0, "refreshed": 0, "candidate": 0, "rejected": 0, "by_reason": {}}
|
||||
|
||||
for source in sources:
|
||||
for cf in source.place_facts:
|
||||
for cf in source.facts:
|
||||
unit_id = unit_map.get(cf.unit_name) if cf.scope == "unit" else None
|
||||
if cf.scope == "unit" and unit_id is None:
|
||||
stat["rejected"] += 1
|
||||
@ -391,10 +391,10 @@ async def store_media(place_id: str, sources: list, unit_map: dict) -> dict:
|
||||
from sqlalchemy import select
|
||||
|
||||
err, rows = await DB_SESSION_MNG.execute_lambda(
|
||||
media.DBType(),
|
||||
place_photos.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: DB_SESSION_MNG.execute(
|
||||
s, select(media).where(media.place_id == uuid.UUID(place_id), media.deleted == False) # noqa: E712
|
||||
s, select(place_photos).where(place_photos.place_id == uuid.UUID(place_id), place_photos.deleted == False) # noqa: E712
|
||||
),
|
||||
)
|
||||
seen = {r.origin_url for r in (rows or []) if r.origin_url} if err == ErrorType.SUCCESS else set()
|
||||
@ -405,7 +405,7 @@ async def store_media(place_id: str, sources: list, unit_map: dict) -> dict:
|
||||
if cm.origin_url in seen:
|
||||
stat["skipped_duplicate"] += 1
|
||||
continue
|
||||
row = media(
|
||||
row = place_photos(
|
||||
place_id=uuid.UUID(place_id),
|
||||
unit_id=unit_map.get(cm.unit_name),
|
||||
url=cm.origin_url, # Vision·재게시 결론 전까지는 원본 URL 을 그대로 둔다
|
||||
@ -417,7 +417,7 @@ async def store_media(place_id: str, sources: list, unit_map: dict) -> dict:
|
||||
sort_order=order,
|
||||
)
|
||||
run_err = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[media.DBType()],
|
||||
[place_photos.DBType()],
|
||||
[lambda s, r=row: DB_SESSION_MNG.insert(s, r, raise_error=False)],
|
||||
)
|
||||
if run_err == ErrorType.SUCCESS:
|
||||
@ -515,7 +515,7 @@ async def run_collect(job: dict) -> dict:
|
||||
# intro 같은 allow_llm 필드에 원문을 넣으면 발행본이 원문으로 덮인다(2026-08-31 사고).
|
||||
if (source.text or "").strip():
|
||||
await DB_SESSION_MNG.execute_lambda_claim(
|
||||
place_links.DBType(),
|
||||
place_channels.DBType(),
|
||||
lambda sess, u=link.url, t=source.text: _place_crud.set_link_raw(
|
||||
sess, uuid.UUID(place_id), u, {"text": t[:8000]},
|
||||
),
|
||||
@ -584,7 +584,7 @@ async def _store_booking_link(place, place_id: str, source) -> bool:
|
||||
f"{place.name} 네이버 예약", SourceType.CRAWL,
|
||||
)
|
||||
await DB_SESSION_MNG.execute_lambda_claim(
|
||||
place_links.DBType(),
|
||||
place_channels.DBType(),
|
||||
lambda s: _place_crud.confirm_link_by_url(s, uuid.UUID(place_id), url, place.verified_by, GTime.UTC()),
|
||||
)
|
||||
LOG.i(f"[collect] 네이버 예약 링크 {'등록·확정' if added else '확정'} — {url}")
|
||||
|
||||
@ -276,7 +276,7 @@ class TourApiAdapter:
|
||||
사장님 사이트의 '숙소 소개' 를 차지하고, 정작 Gemini 가 쓴 162자 소개문은
|
||||
PENDING_OWNER 로 뒤에 밀렸다. 원문을 그대로 싣는 것은 GEO 관점에서도 복제 콘텐츠다.
|
||||
|
||||
원문은 버리지 않는다 — RawSource.text 로 나가 place_links.raw 에 박제되고,
|
||||
원문은 버리지 않는다 — RawSource.text 로 나가 place_channels.raw 에 박제되고,
|
||||
소개문·FAQ 생성 시점에만 근거로 쓰인다(services/copy_service.py).
|
||||
"""
|
||||
return []
|
||||
|
||||
@ -199,7 +199,7 @@ async def run_copy(job: dict) -> dict:
|
||||
place_faqs.DBType(),
|
||||
lambda s: _faq_crud.expire_generated(s, pid, now),
|
||||
)
|
||||
for order, faq in enumerate(copy.place_faqs or []):
|
||||
for order, faq in enumerate(copy.faqs or []):
|
||||
if not faq.fact_keys:
|
||||
# ★ 근거 없는 FAQ 는 저장하지 않는다.
|
||||
stat["rejected"].append([faq.question, "근거 fact 없음"])
|
||||
|
||||
@ -52,7 +52,7 @@ class ChannelDiscovery:
|
||||
"""채널 발견 결과.
|
||||
|
||||
links : 상세 페이지로 판정돼 살아남은 후보 URL. 동일 업소 검증을 통과해야 크롤링 대상이 된다
|
||||
raw : 응답 원문(본문 + search_results). place_links.raw 에 통째로 박제한다
|
||||
raw : 응답 원문(본문 + search_results). place_channels.raw 에 통째로 박제한다
|
||||
search_count : 이번 호출에서 발생한 검색 횟수 — **검색 요금이 토큰 요금과 별도**라 추적한다
|
||||
filtered_out : 걸러낸 URL 과 사유 [(url, reason), ...].
|
||||
★ 조용히 버리지 않는다 — 운영자가 "왜 이 URL 이 빠졌나"를 볼 수 있어야
|
||||
@ -84,7 +84,7 @@ async def discover_channels(
|
||||
"""상호명으로 채널 URL 후보를 찾는다. **URL 발견 전용 — 답변을 사실로 쓰지 마라.**
|
||||
|
||||
돌려주는 URL 은 아직 '이 가게의 것'이라는 보장이 없다. 동일 업소 검증을 통과해
|
||||
확정(place_links.confirmed_at)된 URL 만 크롤링 대상이 된다.
|
||||
확정(place_channels.confirmed_at)된 URL 만 크롤링 대상이 된다.
|
||||
|
||||
발견된 URL 중 **상세 페이지가 아닌 것(루트·목록·SEO 랜딩)과 블로그는 걸러낸다.**
|
||||
걸러낸 목록은 `filtered_out` 에 사유와 함께 남는다 — 조용히 버리지 않는다.
|
||||
|
||||
@ -289,7 +289,7 @@ def _mapxy(item: dict) -> Optional[tuple[float, float]]:
|
||||
async def fetch_content_class(client: httpx.AsyncClient, content_id: str) -> Optional[str]:
|
||||
"""콘텐츠 1건의 중분류 코드(lclsSystm2). 못 구하면 None.
|
||||
|
||||
업장 자신이 TourAPI 에 등록돼 있을 때(place_links 의 tour:// 링크) 그 업장의 업태를 여기서 읽는다 —
|
||||
업장 자신이 TourAPI 에 등록돼 있을 때(place_channels 의 tour:// 링크) 그 업장의 업태를 여기서 읽는다 —
|
||||
주변 맛집에서 같은 중분류를 빼기 위해서다. 외부 분류 문자열 매핑보다 이 값이 우선이다(같은 체계라 오차가 없다).
|
||||
"""
|
||||
items, _ = await _call(client, "detailCommon2", contentId=content_id)
|
||||
|
||||
@ -195,7 +195,7 @@ class FactService:
|
||||
unit_id = unit_map.get(fact.unit_name) if fact.unit_name else None
|
||||
if fact.unit_name and unit_id is None:
|
||||
row.reason = f"'{fact.unit_name}' 단위가 아직 없다 — 수집으로 객실·메뉴가 먼저 만들어져야 한다"
|
||||
res.place_facts.append(row)
|
||||
res.facts.append(row)
|
||||
res.rejected += 1
|
||||
continue
|
||||
|
||||
@ -212,7 +212,7 @@ class FactService:
|
||||
else:
|
||||
row.reason = write.result.desc or "저장 실패"
|
||||
res.rejected += 1
|
||||
res.place_facts.append(row)
|
||||
res.facts.append(row)
|
||||
|
||||
LOG.i(f"[fact] 붙여넣기 추출 — 저장 {res.stored}건 · 반려 {res.rejected}건 (place={place_id})")
|
||||
return res
|
||||
@ -321,7 +321,7 @@ class FactService:
|
||||
res.outcome = FactWriteOutcome.CANDIDATE_UPDATED
|
||||
return await self._reload(res, pid, candidate.fact_id)
|
||||
|
||||
fact = facts(
|
||||
fact = place_facts(
|
||||
place_id=pid,
|
||||
unit_id=req.unit_id,
|
||||
key=req.key,
|
||||
@ -349,7 +349,7 @@ class FactService:
|
||||
|
||||
사람이 넣은 값은 그 사람이 곧 출처이자 책임 주체라 별도 확인 단계를 두지 않는다.
|
||||
기존 노출값은 지우지 않고 EXPIRED 이력으로 남긴다."""
|
||||
fact = facts(
|
||||
fact = place_facts(
|
||||
place_id=pid,
|
||||
unit_id=req.unit_id,
|
||||
key=req.key,
|
||||
|
||||
@ -129,7 +129,7 @@ class FaqService:
|
||||
)
|
||||
sort_order = max((r.sort_order for r in rows), default=-1) + 1
|
||||
|
||||
row = faqs(
|
||||
row = place_faqs(
|
||||
place_id=pid,
|
||||
question=question,
|
||||
answer=answer,
|
||||
|
||||
@ -81,7 +81,7 @@ class MediaService:
|
||||
data = MediaData.model_validate(r)
|
||||
data.publishable = _is_publishable(r)
|
||||
items.append(data)
|
||||
res.place_photos = items
|
||||
res.media = items
|
||||
res.publishable = sum(1 for x in items if x.publishable)
|
||||
# 사람 확인 큐에 남은 수 — 관리 화면의 '검토할 것' 배지.
|
||||
res.pending_review = sum(1 for r in rows if r.status == MediaStatus.PENDING_REVIEW.value)
|
||||
|
||||
@ -341,7 +341,7 @@ class PlaceService:
|
||||
# 실측상 Perplexity 는 이 채널을 잘 못 찾는다 — 검증 단계에서 건지는 게 확실하다.
|
||||
# 등록만 하고 확정하지는 않는다(확정은 수집 잡이 어댑터 유무를 보고 판단).
|
||||
if (req.place_url or "").strip():
|
||||
link = place_links(
|
||||
link = place_channels(
|
||||
place_id=uuid.UUID(place_id),
|
||||
channel=LinkChannel.OFFICIAL_SITE.value,
|
||||
url=req.place_url.strip(),
|
||||
@ -383,7 +383,7 @@ class PlaceService:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
|
||||
unit = units(place_id=uuid.UUID(place_id), name=req.name.strip(), sort_order=req.sort_order)
|
||||
unit = place_units(place_id=uuid.UUID(place_id), name=req.name.strip(), sort_order=req.sort_order)
|
||||
run_err = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[place_units.DBType()],
|
||||
[lambda s: self.crud.add_unit(s, unit)],
|
||||
@ -426,7 +426,7 @@ class PlaceService:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
|
||||
link = place_links(
|
||||
link = place_channels(
|
||||
place_id=uuid.UUID(place_id),
|
||||
channel=req.channel.value,
|
||||
url=req.url.strip(),
|
||||
@ -572,18 +572,18 @@ class PlaceService:
|
||||
res.result.SetResult(ErrorType.GENERATOR_NOT_CONFIGURED)
|
||||
return res
|
||||
|
||||
conds = [media.place_id == uuid.UUID(place_id), media.deleted == False] # noqa: E712
|
||||
conds = [place_photos.place_id == uuid.UUID(place_id), place_photos.deleted == False] # noqa: E712
|
||||
if not req.force:
|
||||
# ★ 미분석 기준은 alt_text 다 — label 은 수집 어댑터가 페이지 캡션으로 채운다.
|
||||
# label 로 세면 캡션 있는 사진이 전부 '분석됨'으로 빠져 pending 0 이 된다
|
||||
# (crud/media_crud.list_media 의 같은 주석 참고).
|
||||
from sqlalchemy import func as sa_func
|
||||
from sqlalchemy import or_ as sa_or
|
||||
conds.append(sa_or(media.alt_text.is_(None), sa_func.btrim(media.alt_text) == ""))
|
||||
conds.append(sa_or(place_photos.alt_text.is_(None), sa_func.btrim(place_photos.alt_text) == ""))
|
||||
cnt_err, rows = await DB_SESSION_MNG.execute_lambda(
|
||||
media.DBType(),
|
||||
place_photos.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: DB_SESSION_MNG.execute(s, select(func.count()).select_from(media).where(*conds)),
|
||||
lambda s: DB_SESSION_MNG.execute(s, select(func.count()).select_from(place_photos).where(*conds)),
|
||||
)
|
||||
res.pending_media = int(rows[0] or 0) if cnt_err == ErrorType.SUCCESS and rows else 0
|
||||
if res.pending_media == 0:
|
||||
|
||||
@ -140,19 +140,13 @@ class SiteService:
|
||||
if v_err != ErrorType.SUCCESS:
|
||||
version = None
|
||||
|
||||
_ai_err, ai_rows = await DB_SESSION_MNG.execute_lambda(
|
||||
ai_check_results.DBType(), DBWRType.DB_READ.value,
|
||||
lambda s: DB_SESSION_MNG.execute(
|
||||
s, select(ai_check_results).where(
|
||||
ai_check_results.place_id == uuid.UUID(place_id),
|
||||
ai_check_results.deleted == False, # noqa: E712
|
||||
).order_by(ai_check_results.checked_at.desc()).limit(20),
|
||||
raise_error=False,
|
||||
),
|
||||
)
|
||||
# ★ AI 노출 점검 이력은 읽지 않는다. `place_ai_checks` 는 한 번도 쓰지 않아
|
||||
# 마이그레이션 0006 이 뗐다(JobType.AI_CHECK 도 아직 미배선이다).
|
||||
# 여기서 그 표를 계속 부르면 SEO 진단이 통째로 죽는다 — 지금 그랬다.
|
||||
# reports 모듈이 붙는 날 표와 함께 되살린다.
|
||||
report = evaluate(
|
||||
await build_snapshot(place), verified=place.verified_at is not None,
|
||||
site=site, version=version, ai_checks=list(ai_rows or []),
|
||||
site=site, version=version, ai_checks=[],
|
||||
)
|
||||
return Res_SeoAudit(**report)
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
# WORKER_CONCURRENCY=3 python worker_main.py
|
||||
#
|
||||
# 수집·비전분석·빌드는 몇 분씩 걸려 동기 요청으로 처리할 수 없다. API 는 잡만 적재하고 즉시 응답하며,
|
||||
# 실제 처리는 이 프로세스가 한다. 큐는 PostgreSQL(job.jobs) — 원자적 claim + lease 소유권이라
|
||||
# 실제 처리는 이 프로세스가 한다. 큐는 PostgreSQL(jobs) — 원자적 claim + lease 소유권이라
|
||||
# 워커를 몇 개 띄우든(docker compose --scale) 같은 잡이 두 번 돌지 않는다.
|
||||
|
||||
import asyncio
|
||||
|
||||
Loading…
Reference in New Issue
Block a user