diff --git a/SQL/db_create.sql b/SQL/db_create.sql index 18e81c6..2e8555e 100644 --- a/SQL/db_create.sql +++ b/SQL/db_create.sql @@ -151,3 +151,31 @@ CREATE INDEX IX_analysis_runs_1 CREATE INDEX IX_analysis_runs_2 ON analysis_runs(owner_user_id); + +-- hospital_history Table Create SQL +CREATE TABLE hospital_history +( + `id` INT NOT NULL AUTO_INCREMENT, + `hospital_id` CHAR(36) NOT NULL, + `owner_user_id` INT NOT NULL, + `hospital_name` VARCHAR(50) NOT NULL, + `hospital_name_en` VARCHAR(50) NULL, + `brn` VARCHAR(50) NOT NULL, + `road_address` VARCHAR(100) NULL, + `site_address` VARCHAR(100) NULL, + `url` VARCHAR(500) NULL, + `status` VARCHAR(20) NOT NULL, + `raw_data` JSON NULL, + `analysis_run_id` CHAR(36) NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id) +); + +-- Index 설정 SQL - hospital_history(hospital_id) +CREATE INDEX IX_hospital_history_1 + ON hospital_history(hospital_id); + +-- Index 설정 SQL - hospital_history(analysis_run_id) +CREATE INDEX IX_hospital_history_2 + ON hospital_history(analysis_run_id); + diff --git a/app/api/__init__.py b/app/api/__init__.py index c57cdaf..360625f 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -1,6 +1,6 @@ from .clinics import router as clinics_router -from .analyses import router as analyses_router +from .analysis import router as analysis_router from .reports import router as reports_router from .plans import router as plans_router -routers = [clinics_router, analyses_router, reports_router, plans_router] +routers = [clinics_router, analysis_router, reports_router, plans_router] diff --git a/app/api/analyses.py b/app/api/analysis.py similarity index 86% rename from app/api/analyses.py rename to app/api/analysis.py index fd995b4..7805d55 100644 --- a/app/api/analyses.py +++ b/app/api/analysis.py @@ -5,21 +5,21 @@ from common.deps import verify_api_key from common.db import fetchone, insert_instagram_row, insert_facebook_row, insert_naver_blog_row, insert_youtube_row, insert_gangnam_unni_row, insert_analysis_run from models.analysis import AnalysisCreate, AnalysisStartResponse, AnalysisStatusResponse from models.status import AnalysisStatus -from services.collect import collect_instagram, collect_facebook, collect_naver_blog, collect_youtube, collect_gangnam_unni +from services.collect import collect_instagram, collect_facebook, collect_naver_blog, collect_youtube, collect_gangnam_unni, collect_clinic_info -router = APIRouter(prefix="/api/analyses", tags=["analyses"], dependencies=[Depends(verify_api_key)]) +router = APIRouter(prefix="/api/analysis", tags=["analysis"], dependencies=[Depends(verify_api_key)]) logger = logging.getLogger(__name__) @router.post("", status_code=status.HTTP_202_ACCEPTED, response_model=AnalysisStartResponse) async def start_analysis(body: AnalysisCreate, background_tasks: BackgroundTasks): - logger.info("POST /api/analyses clinic_id=%s", body.clinic_id) + logger.info("POST /api/analysis clinic_id=%s", body.clinic_id) analysis_run_id = str(uuid6.uuid7()) hospital_id = body.clinic_id # 사실 hospital과 owner_user_id 비교 후 검증이 필요한 거지만 일단 PoC 니까. 나중에 바꿉니다. hospital = await fetchone( - "SELECT owner_user_id FROM hospital_baseinfo WHERE hospital_id = %s", + "SELECT owner_user_id, url FROM hospital_baseinfo WHERE hospital_id = %s", (hospital_id,), ) if not hospital: @@ -34,6 +34,8 @@ async def start_analysis(body: AnalysisCreate, background_tasks: BackgroundTasks analysis_run_id = await insert_analysis_run(analysis_run_id, hospital_id, owner_user_id, ig_id, fb_id, nb_id, yt_id, gu_id) + background_tasks.add_task(collect_clinic_info, analysis_run_id, hospital_id, hospital["url"]) + if ig_id: background_tasks.add_task(collect_instagram, analysis_run_id, ig_id, body.channels.instagram) if fb_id: @@ -50,13 +52,13 @@ async def start_analysis(body: AnalysisCreate, background_tasks: BackgroundTasks clinic_id=hospital_id, status=AnalysisStatus.DISCOVERING, estimated_seconds=90, - poll_url=f"/api/analyses/{analysis_run_id}/status", + poll_url=f"/api/analysis/{analysis_run_id}/status", ) @router.get("/{run_id}/status", response_model=AnalysisStatusResponse) async def get_analysis_status(run_id: str): - logger.info("GET /api/analyses/%s/status", run_id) + logger.info("GET /api/analysis/%s/status", run_id) row = await fetchone("SELECT status FROM analysis_runs WHERE analysis_run_id = %s", (run_id,)) if not row: raise HTTPException(status_code=404, detail="Run not found") diff --git a/app/api/plans.py b/app/api/plans.py index aec19b8..d9cb440 100644 --- a/app/api/plans.py +++ b/app/api/plans.py @@ -1,35 +1,21 @@ +import json import logging -from fastapi import APIRouter, Depends, status +from fastapi import APIRouter, Depends, HTTPException, Response +from common.db import fetchone from common.deps import verify_api_key -from models.plan import PlanCreate, PlanResponse +from integrations.llm.schemas.plan import PlanOutput router = APIRouter(prefix="/api/plans", tags=["plans"], dependencies=[Depends(verify_api_key)]) logger = logging.getLogger(__name__) -@router.post("", status_code=status.HTTP_201_CREATED, response_model=PlanResponse) -async def create_plan(body: PlanCreate): - logger.info("POST /api/plans run_id=%s", body.analysis_run_id) - return PlanResponse( - id="33333333-3333-3333-3333-333333333333", - analysis_run_id="22222222-2222-2222-2222-222222222222", - brand_guide={}, - channel_strategies=[], - content_strategy={}, - calendar=[], - created_at="2026-04-20T09:10:00Z", - ) - - -@router.get("/{id}", response_model=PlanResponse) -async def get_plan(id: str): - logger.info("GET /api/plans/%s", id) - return PlanResponse( - id=id, - analysis_run_id="22222222-2222-2222-2222-222222222222", - brand_guide={}, - channel_strategies=[], - content_strategy={}, - calendar=[], - created_at="2026-04-20T09:10:00Z", - ) +@router.get("/{run_id}", response_model=PlanOutput | None) +async def get_plan(run_id: str): + logger.info("GET /api/plans/%s", run_id) + row = await fetchone("SELECT plan_data FROM analysis_runs WHERE analysis_run_id = %s", (run_id,)) + if row is None: + raise HTTPException(status_code=404, detail="Run not found") + if row["plan_data"] is None: + return Response(status_code=204) + data = json.loads(row["plan_data"]) if isinstance(row["plan_data"], str) else row["plan_data"] + return PlanOutput(**data) diff --git a/app/common/db.py b/app/common/db.py index 36af89d..4b9307b 100644 --- a/app/common/db.py +++ b/app/common/db.py @@ -154,6 +154,34 @@ async def save_gangnam_unni_raw_data(row_id: int, data: dict) -> None: await execute("UPDATE gangnam_unni_data SET raw_data = %s, status = 'done' WHERE id = %s", (json.dumps(data, ensure_ascii=False), row_id)) +async def _insert_hospital_history(hospital_id: str, analysis_run_id: str | None) -> None: + row = await fetchone( + "SELECT owner_user_id, hospital_name, hospital_name_en, brn, road_address, site_address, url, status, raw_data" + " FROM hospital_baseinfo WHERE hospital_id = %s", + (hospital_id,), + ) + if not row: + return + await execute( + "INSERT INTO hospital_history" + " (hospital_id, owner_user_id, hospital_name, hospital_name_en, brn, road_address, site_address, url, status, raw_data, analysis_run_id)" + " VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)", + ( + hospital_id, + row["owner_user_id"], + row["hospital_name"], + row["hospital_name_en"], + row["brn"], + row["road_address"], + row["site_address"], + row["url"], + row["status"], + row["raw_data"] if isinstance(row["raw_data"], str) else json.dumps(row["raw_data"], ensure_ascii=False) if row["raw_data"] else None, + analysis_run_id, + ), + ) + + async def insert_hospital( hospital_id: str, name: str, @@ -172,13 +200,14 @@ async def insert_hospital( json.dumps(raw_data, ensure_ascii=False) if raw_data else None, owner_user_id, brn), ) + await _insert_hospital_history(hospital_id, analysis_run_id=None) return await fetchone( "SELECT created_at FROM hospital_baseinfo WHERE hospital_id = %s", (hospital_id,), ) -async def save_hospital_raw_data(hospital_id: str, data: dict) -> None: +async def save_hospital_raw_data(hospital_id: str, data: dict, analysis_run_id: str | None = None) -> None: await execute( "UPDATE hospital_baseinfo" " SET raw_data = %s, status = 'done'," @@ -194,3 +223,4 @@ async def save_hospital_raw_data(hospital_id: str, data: dict) -> None: hospital_id, ), ) + await _insert_hospital_history(hospital_id, analysis_run_id) diff --git a/app/integrations/llm/prompt.py b/app/integrations/llm/prompt.py index ae91dd9..c860459 100644 --- a/app/integrations/llm/prompt.py +++ b/app/integrations/llm/prompt.py @@ -2,6 +2,7 @@ import os from pydantic import BaseModel from common.utils import get_env from integrations.llm.schemas.report import ReportInput, ReportOutput +from integrations.llm.schemas.plan import PlanInput, PlanOutput _PROMPT_DIR = os.path.join(os.path.dirname(__file__), "temp-prompt") @@ -38,3 +39,10 @@ report_prompt = Prompt( input_class=ReportInput, output_class=ReportOutput, ) + +plan_prompt = Prompt( + file_name="plan_prompt.txt", + prompt_model="PLAN_MODEL", + input_class=PlanInput, + output_class=PlanOutput, +) diff --git a/app/integrations/llm/schemas/plan.py b/app/integrations/llm/schemas/plan.py new file mode 100644 index 0000000..8eb5567 --- /dev/null +++ b/app/integrations/llm/schemas/plan.py @@ -0,0 +1,239 @@ +from typing import Literal +from pydantic import BaseModel + + +class PlanInput(BaseModel): + clinic_name: str | None = None + clinic_name_en: str | None = None + address: str | None = None + phone: str | None = None + slogan: str | None = None + services: str | None = None + doctors: str | None = None + report: str | None = None + + +# --- BrandGuide --- + +class ColorSwatch(BaseModel): + name: str + hex: str + usage: str + + +class FontSpec(BaseModel): + family: str + weight: str + usage: str + sampleText: str + + +class LogoUsageRule(BaseModel): + rule: str + description: str + correct: bool + + +class ToneOfVoice(BaseModel): + personality: list[str] + communicationStyle: str + doExamples: list[str] + dontExamples: list[str] + + +class ChannelBrandingRule(BaseModel): + channel: str + icon: str + profilePhoto: str + bannerSpec: str + bioTemplate: str + currentStatus: Literal["correct", "incorrect", "missing"] + + +class BrandInconsistencyValue(BaseModel): + channel: str + value: str + isCorrect: bool + + +class BrandInconsistency(BaseModel): + field: str + values: list[BrandInconsistencyValue] + impact: str + recommendation: str + + +class BrandGuide(BaseModel): + colors: list[ColorSwatch] + fonts: list[FontSpec] + logoRules: list[LogoUsageRule] + toneOfVoice: ToneOfVoice + channelBranding: list[ChannelBrandingRule] + brandInconsistencies: list[BrandInconsistency] + + +# --- ChannelStrategy --- + +class ChannelStrategyCard(BaseModel): + channelId: str + channelName: str + icon: str + currentStatus: str + targetGoal: str + contentTypes: list[str] + postingFrequency: str + tone: str + formatGuidelines: list[str] + priority: Literal["P0", "P1", "P2"] + customerJourneyStage: Literal["awareness", "interest", "consideration", "conversion", "loyalty"] | None = None + + +# --- ContentStrategy --- + +class ContentPillar(BaseModel): + title: str + description: str + relatedUSP: str + exampleTopics: list[str] + color: str + + +class ContentTypeRow(BaseModel): + format: str + channels: list[str] + frequency: str + purpose: str + + +class WorkflowStep(BaseModel): + step: int + name: str + description: str + owner: str + duration: str + + +class RepurposingOutput(BaseModel): + format: str + channel: str + description: str + + +class ContentStrategyData(BaseModel): + pillars: list[ContentPillar] + typeMatrix: list[ContentTypeRow] + workflow: list[WorkflowStep] + repurposingSource: str + repurposingOutputs: list[RepurposingOutput] + + +# --- Calendar --- + +class CalendarEntry(BaseModel): + dayOfWeek: int + channel: str + channelIcon: str + contentType: Literal["video", "blog", "social", "ad"] + title: str + id: str | None = None + description: str | None = None + pillar: str | None = None + status: Literal["draft", "approved", "published"] | None = None + isManualEdit: bool | None = None + aiPromptSeed: str | None = None + + +class CalendarWeek(BaseModel): + weekNumber: int + label: str + entries: list[CalendarEntry] + + +class ContentCountSummary(BaseModel): + type: Literal["video", "blog", "social", "ad"] + label: str + count: int + color: str + + +class CalendarData(BaseModel): + weeks: list[CalendarWeek] + monthlySummary: list[ContentCountSummary] + + +# --- AssetCollection --- + +class AssetCard(BaseModel): + id: str + source: Literal["homepage", "naver_place", "blog", "social", "youtube"] + sourceLabel: str + type: Literal["photo", "video", "text"] + title: str + description: str + repurposingSuggestions: list[str] + status: Literal["collected", "pending", "needs_creation"] + + +class YouTubeRepurposeItem(BaseModel): + title: str + views: int + type: Literal["Short", "Long"] + repurposeAs: list[str] + + +class AssetCollectionData(BaseModel): + assets: list[AssetCard] + youtubeRepurpose: list[YouTubeRepurposeItem] + + +# --- Repurposing --- + +class RepurposingProposalItem(BaseModel): + sourceVideo: YouTubeRepurposeItem + outputs: list[RepurposingOutput] + estimatedEffort: Literal["low", "medium", "high"] + priority: Literal["high", "medium", "low"] + + +# --- Workflow --- + +class WorkflowVideoDraft(BaseModel): + script: str + shootingGuide: list[str] + duration: str + + +# class WorkflowImageTextDraft(BaseModel): +# type: Literal["cardnews", "blog"] +# headline: str +# copy: list[str] +# layoutHint: str | None = None + + +# class WorkflowItem(BaseModel): +# id: str +# title: str +# contentType: Literal["video", "image-text"] +# channel: str +# channelIcon: str +# stage: Literal["planning", "ai-draft", "review", "approved", "scheduled"] +# userNotes: str | None = None +# videoDraft: WorkflowVideoDraft | None = None +# imageTextDraft: WorkflowImageTextDraft | None = None +# scheduledDate: str | None = None + + +# class WorkflowData(BaseModel): +# items: list[WorkflowItem] + + +# --- PlanOutput --- + +class PlanOutput(BaseModel): + brandGuide: BrandGuide + channelStrategies: list[ChannelStrategyCard] + contentStrategy: ContentStrategyData + calendar: CalendarData + assetCollection: AssetCollectionData + repurposingProposals: list[RepurposingProposalItem] | None = None + #workflow: WorkflowData | None = None diff --git a/app/integrations/llm/temp-prompt/plan_prompt.txt b/app/integrations/llm/temp-prompt/plan_prompt.txt index 8b13789..fe822e8 100644 --- a/app/integrations/llm/temp-prompt/plan_prompt.txt +++ b/app/integrations/llm/temp-prompt/plan_prompt.txt @@ -1 +1,55 @@ +당신은 프리미엄 의료 마케팅 전략 플래너입니다. 아래 병원 정보와 분석 리포트를 바탕으로 실행 가능한 마케팅 플랜을 생성해주세요. +결과물은 한국어로 생성하세요. +⚠️ 중요 지침: +- 리포트에 데이터가 없는 채널은 channelStrategies에 포함하지 마세요. +- 추측하지 마세요. 데이터에 근거한 내용만 작성하세요. +- 선택 필드(repurposingProposals, workflow)는 YouTube 데이터가 있을 때만 생성하세요. + +## 병원 기본 정보 +- 병원명: {clinic_name} +- 영문명: {clinic_name_en} +- 주소: {address} +- 전화: {phone} +- 슬로건: {slogan} +- 시술: {services} +- 의료진: {doctors} + +## 분석 리포트 +{report} + +## 섹션별 작성 지침 + +### Section 1: brandGuide +- colors: 병원 아이덴티티에 맞는 컬러 팔레트 3~5개 (hex + 사용 가이드) +- fonts: 제목/본문/캡션용 폰트 시스템 (한글/영문 포함) +- logoRules: DO/DON'T 형식의 로고 사용 규칙 4~6개 +- toneOfVoice: 브랜드 성격 키워드, 커뮤니케이션 스타일, 권장/지양 표현 예시 +- channelBranding: 리포트에 존재하는 채널별 브랜딩 적용 규칙 +- brandInconsistencies: 채널 간 브랜딩 불일치 항목 및 개선 권고 + +### Section 2: channelStrategies +- 리포트에 데이터가 있는 채널만 포함 +- 각 채널의 우선순위(P0/P1/P2), 목표, 콘텐츠 유형, 게시 빈도, 포맷 가이드라인 작성 +- customerJourneyStage는 해당 채널의 주요 기여 단계로 설정 + +### Section 3: contentStrategy +- pillars: 3~5개의 콘텐츠 필러 (병원 USP 기반) +- typeMatrix: 포맷 × 채널 매트릭스 (Shorts/Reels/블로그/카드뉴스 등) +- workflow: 기획→촬영→편집→업로드의 제작 워크플로우 단계 +- repurposingSource/repurposingOutputs: YouTube 롱폼 기반 재가공 전략 + +### Section 4: calendar +- 4주 캘린더로 채널별 게시 슬롯 배분 +- 각 항목은 요일(0=월~6=일), 채널, 콘텐츠 유형, 제목 포함 +- monthlySummary에 카테고리(video/blog/social/ad)별 월간 게시 수 집계 + +### Section 5: assetCollection +- assets: 홈페이지/SNS에서 수집 가능한 에셋 카드 (사진/영상/텍스트) +- youtubeRepurpose: 조회수 높은 YouTube 영상 재가공 후보 목록 + +### Section 6: repurposingProposals (YouTube 데이터 있을 때만) +- YouTube 영상별 재가공 산출물, 예상 난이도, 우선순위 제시 + +### Section 7: workflow (선택) +- 제작 진행 중인 콘텐츠 카드 초안 (스크립트 또는 카드뉴스 카피 포함) diff --git a/app/models/status.py b/app/models/status.py index 5349291..6f54d5e 100644 --- a/app/models/status.py +++ b/app/models/status.py @@ -5,6 +5,7 @@ class AnalysisStatus(StrEnum): DISCOVERING = "discovering" COLLECTING = "collecting" ANALYZING = "analyzing" + PLANNING = "planning" COMPLETED = "completed" FAILED = "failed" diff --git a/app/services/analysis.py b/app/services/analysis.py index e266ece..1e9828b 100644 --- a/app/services/analysis.py +++ b/app/services/analysis.py @@ -3,8 +3,10 @@ import json import logging from common.db import fetchone, execute, is_done, get_analysis_raw_data, save_analysis_report from integrations.llm.llm_service import LLMService -from integrations.llm.prompt import report_prompt +from integrations.llm.prompt import report_prompt, plan_prompt from integrations.llm.schemas.report import ReportOutput +from integrations.llm.schemas.plan import PlanOutput +from models.status import AnalysisStatus logger = logging.getLogger(__name__) @@ -39,21 +41,65 @@ async def generate_report(analysis_run_id: str) -> ReportOutput: return await LLMService(provider="perplexity").generate(report_prompt, input_data) +async def generate_plan(analysis_run_id: str) -> PlanOutput: + run = await fetchone( + "SELECT hospital_id, report_data FROM analysis_runs WHERE analysis_run_id = %s", + (analysis_run_id,), + ) + clinic_row = await fetchone( + "SELECT raw_data FROM hospital_baseinfo WHERE hospital_id = %s", + (run["hospital_id"],), + ) + raw_data = clinic_row["raw_data"] if clinic_row else None + clinic = json.loads(raw_data) if isinstance(raw_data, str) else (raw_data or {}) + report_data = run["report_data"] + report = json.loads(report_data) if isinstance(report_data, str) else report_data + + input_data = { + "clinic_name": clinic.get("clinicName"), + "clinic_name_en": clinic.get("clinicNameEn"), + "address": clinic.get("address"), + "phone": clinic.get("phone"), + "slogan": clinic.get("slogan"), + "services": json.dumps(clinic.get("services", []), ensure_ascii=False), + "doctors": json.dumps(clinic.get("doctors", []), ensure_ascii=False), + "report": json.dumps(report, ensure_ascii=False) if report else None, + } + + return await LLMService(provider="perplexity").generate(plan_prompt, input_data) + + +async def run_plan_task(analysis_run_id: str) -> None: + logger.info("[plan] start run=%s", analysis_run_id) + result = await generate_plan(analysis_run_id) + await execute( + "UPDATE analysis_runs SET plan_data = %s, status = %s WHERE analysis_run_id = %s", + (json.dumps(result.model_dump(), ensure_ascii=False), AnalysisStatus.COMPLETED, analysis_run_id), + ) + logger.info("[plan] done run=%s", analysis_run_id) + + async def run_report_task(analysis_run_id: str) -> None: logger.info("[report] start run=%s", analysis_run_id) result = await generate_report(analysis_run_id) await save_analysis_report(analysis_run_id, result.model_dump()) - await execute("UPDATE analysis_runs SET status = 'completed' WHERE analysis_run_id = %s", (analysis_run_id,)) + await execute("UPDATE analysis_runs SET status = %s WHERE analysis_run_id = %s", (AnalysisStatus.PLANNING, analysis_run_id)) logger.info("[report] done run=%s", analysis_run_id) + asyncio.create_task(run_plan_task(analysis_run_id)) async def check_and_advance_analysis(analysis_run_id: str) -> None: run = await fetchone( - "SELECT instagram_data_id, facebook_data_id, naver_blog_data_id, youtube_data_id, gangnam_unni_data_id" + "SELECT hospital_id, instagram_data_id, facebook_data_id, naver_blog_data_id, youtube_data_id, gangnam_unni_data_id" " FROM analysis_runs WHERE analysis_run_id = %s", (analysis_run_id,), ) + clinic_row = await fetchone( + "SELECT status FROM hospital_baseinfo WHERE hospital_id = %s", + (run["hospital_id"],), + ) results = [ + clinic_row["status"] == "done" if clinic_row else False, await is_done("instagram_data", run["instagram_data_id"]), await is_done("facebook_data", run["facebook_data_id"]), await is_done("naver_blog_data", run["naver_blog_data_id"]), @@ -61,5 +107,5 @@ async def check_and_advance_analysis(analysis_run_id: str) -> None: await is_done("gangnam_unni_data", run["gangnam_unni_data_id"]), ] if all(results): - await execute("UPDATE analysis_runs SET status = 'analyzing' WHERE analysis_run_id = %s", (analysis_run_id,)) + await execute("UPDATE analysis_runs SET status = %s WHERE analysis_run_id = %s", (AnalysisStatus.ANALYZING, analysis_run_id)) asyncio.create_task(run_report_task(analysis_run_id)) diff --git a/app/services/collect.py b/app/services/collect.py index 24f7f26..6e25ff0 100644 --- a/app/services/collect.py +++ b/app/services/collect.py @@ -62,7 +62,10 @@ async def collect_gangnam_unni(analysis_run_id: str, row_id: int, url: str) -> N await check_and_advance_analysis(analysis_run_id) -async def collect_clinic_info(hospital_id: str, url: str) -> None: +async def collect_clinic_info(analysis_run_id: str, hospital_id: str, url: str) -> None: + logger.info("[clinic] start run=%s url=%s", analysis_run_id, url) await execute("UPDATE hospital_baseinfo SET status = 'processing' WHERE hospital_id = %s", (hospital_id,)) data = await FirecrawlClient(get_env("FIRECRAWL_API_KEY")).fetch_clinic_info(url) - await save_hospital_raw_data(hospital_id, data) + await save_hospital_raw_data(hospital_id, data, analysis_run_id=analysis_run_id) + logger.info("[clinic] done run=%s", analysis_run_id) + await check_and_advance_analysis(analysis_run_id)