[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:
Mina Choi 2026-09-09 17:08:51 +09:00
parent c4af53613e
commit 387783b766
13 changed files with 56 additions and 62 deletions

View File

@ -259,7 +259,7 @@ FACT_STATUS_TRANSITIONS = {
class LinkChannel(CodeEnum):
"""place_links.channel 코드값. Perplexity 가 발견하는 채널 종류."""
"""place_channels.channel 코드값. Perplexity 가 발견하는 채널 종류."""
YANOLJA = 1 # 야놀자
GOODCHOICE = 2 # 여기어때

View File

@ -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

View File

@ -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}")

View File

@ -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 []

View File

@ -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 없음"])

View File

@ -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` 에 사유와 함께 남는다 — 조용히 버리지 않는다.

View File

@ -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)

View File

@ -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,

View File

@ -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,

View File

@ -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)

View File

@ -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:

View File

@ -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)

View File

@ -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