diff --git a/solution/backend/common/enums.py b/solution/backend/common/enums.py index 601a1fb..b0c6c41 100644 --- a/solution/backend/common/enums.py +++ b/solution/backend/common/enums.py @@ -259,7 +259,7 @@ FACT_STATUS_TRANSITIONS = { class LinkChannel(CodeEnum): - """place_links.channel 코드값. Perplexity 가 발견하는 채널 종류.""" + """place_channels.channel 코드값. Perplexity 가 발견하는 채널 종류.""" YANOLJA = 1 # 야놀자 GOODCHOICE = 2 # 여기어때 diff --git a/solution/backend/crud/job_crud.py b/solution/backend/crud/job_crud.py index 56a6362..00fbafe 100644 --- a/solution/backend/crud/job_crud.py +++ b/solution/backend/crud/job_crud.py @@ -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 diff --git a/solution/backend/services/collect_service.py b/solution/backend/services/collect_service.py index c837146..9fef1a3 100644 --- a/solution/backend/services/collect_service.py +++ b/solution/backend/services/collect_service.py @@ -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}") diff --git a/solution/backend/services/collector/tour_api_adapter.py b/solution/backend/services/collector/tour_api_adapter.py index 650c8ba..fe92984 100644 --- a/solution/backend/services/collector/tour_api_adapter.py +++ b/solution/backend/services/collector/tour_api_adapter.py @@ -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 [] diff --git a/solution/backend/services/copy_service.py b/solution/backend/services/copy_service.py index 069ed16..bd208cc 100644 --- a/solution/backend/services/copy_service.py +++ b/solution/backend/services/copy_service.py @@ -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 없음"]) diff --git a/solution/backend/services/external/perplexity.py b/solution/backend/services/external/perplexity.py index 8f185eb..c4507e2 100644 --- a/solution/backend/services/external/perplexity.py +++ b/solution/backend/services/external/perplexity.py @@ -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` 에 사유와 함께 남는다 — 조용히 버리지 않는다. diff --git a/solution/backend/services/external/tour_api.py b/solution/backend/services/external/tour_api.py index 895577f..db90265 100644 --- a/solution/backend/services/external/tour_api.py +++ b/solution/backend/services/external/tour_api.py @@ -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) diff --git a/solution/backend/services/fact_service.py b/solution/backend/services/fact_service.py index 4923339..30d2e56 100644 --- a/solution/backend/services/fact_service.py +++ b/solution/backend/services/fact_service.py @@ -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, diff --git a/solution/backend/services/faq_service.py b/solution/backend/services/faq_service.py index 1da4dcd..c5f7142 100644 --- a/solution/backend/services/faq_service.py +++ b/solution/backend/services/faq_service.py @@ -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, diff --git a/solution/backend/services/media_service.py b/solution/backend/services/media_service.py index 1f40c1b..cd5706b 100644 --- a/solution/backend/services/media_service.py +++ b/solution/backend/services/media_service.py @@ -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) diff --git a/solution/backend/services/place_service.py b/solution/backend/services/place_service.py index 34227f4..1a4961b 100644 --- a/solution/backend/services/place_service.py +++ b/solution/backend/services/place_service.py @@ -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: diff --git a/solution/backend/services/site_service.py b/solution/backend/services/site_service.py index da100fd..fa6ae7b 100644 --- a/solution/backend/services/site_service.py +++ b/solution/backend/services/site_service.py @@ -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) diff --git a/solution/backend/worker_main.py b/solution/backend/worker_main.py index c1aa763..8e79f55 100644 --- a/solution/backend/worker_main.py +++ b/solution/backend/worker_main.py @@ -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