From 387aa758d0b14988b0e8e2a5aff0fda89cf9f357 Mon Sep 17 00:00:00 2001 From: jaehwang Date: Thu, 18 Jun 2026 10:19:44 +0900 Subject: [PATCH] =?UTF-8?q?plan=20=EC=9D=B8=ED=92=8B=20summary=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20firecrawl=20=EC=BA=90=EC=8B=B1=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/integrations/firecrawl.py | 6 ++- app/integrations/llm/prompt.py | 9 +++- app/integrations/llm/schemas/plan.py | 9 ++++ .../llm/temp-prompt/summarize_prompt.txt | 7 +++ app/services/analysis.py | 47 ++++++++++++++----- 5 files changed, 62 insertions(+), 16 deletions(-) create mode 100644 app/integrations/llm/temp-prompt/summarize_prompt.txt diff --git a/app/integrations/firecrawl.py b/app/integrations/firecrawl.py index f3313de..12b1137 100644 --- a/app/integrations/firecrawl.py +++ b/app/integrations/firecrawl.py @@ -16,7 +16,7 @@ class FirecrawlClient: HTTPMethod.POST, url=f"{FIRECRAWL_BASE}/scrape", headers=self._headers(), - json_body={"url": url, "formats": ["json", "links"], "jsonOptions": json_options, "waitFor": wait_for}, + json_body={"url": url, "formats": ["json", "links"], "jsonOptions": json_options, "waitFor": wait_for, "maxAge": 0}, label="firecrawl-scrape", ) if not resp or not resp.is_success: @@ -76,7 +76,7 @@ class FirecrawlClient: "url": url, "formats": ["json", "links", "rawHtml"], "jsonOptions": { - "prompt": "Extract: clinic name (Korean), clinic name (English), address, phone with dash format, business hours, slogan, services offered, doctors with name/title/specialty, brand identity (primary/accent/background/text colors in hex, heading/body fonts, logo URL from the actual header/main src, og:image from content, favicon URL)", + "prompt": "Extract: 클리닉 이름 - clinicName (Korean), clinic name (English), address, phone with dash format, business hours, slogan, services offered, doctors with name/title/specialty, brand identity (primary/accent/background/text colors in hex, heading/body fonts, logo URL from the actual header/main src, og:image from content, favicon URL)", "schema": { "type": "object", "properties": { @@ -127,6 +127,7 @@ class FirecrawlClient: }, }, "waitFor": 5000, + "maxAge": 0, }, timeout=60, label="firecrawl-clinic-info", @@ -187,6 +188,7 @@ class FirecrawlClient: }, }, "waitFor": 5000, + "maxAge": 0, }, timeout=60, label="firecrawl-gangnamunni", diff --git a/app/integrations/llm/prompt.py b/app/integrations/llm/prompt.py index 6f9c646..79cbb14 100644 --- a/app/integrations/llm/prompt.py +++ b/app/integrations/llm/prompt.py @@ -13,7 +13,7 @@ from integrations.llm.schemas.report import ( ScoresInput, ScoresOutput, OtherChannelsInput, OtherChannelsOutput ) -from integrations.llm.schemas.plan import PlanInput, PlanOutput +from integrations.llm.schemas.plan import PlanInput, PlanOutput, SummarizeInput, SummarizeOutput from integrations.llm.schemas.market import ( MarketCompetitorsInput, MarketCompetitorsOutput, MarketKeywordsInput, MarketKeywordsOutput, @@ -64,6 +64,13 @@ plan_prompt = Prompt( output_class=PlanOutput, ) +summarize_prompt = Prompt( + file_name="summarize_prompt.txt", + prompt_model="PLAN_MODEL", + input_class=SummarizeInput, + output_class=SummarizeOutput, +) + market_competitors_prompt = Prompt( file_name="market_competitors_prompt.txt", prompt_model="MARKET_MODEL", diff --git a/app/integrations/llm/schemas/plan.py b/app/integrations/llm/schemas/plan.py index fc2c8cd..24e787c 100644 --- a/app/integrations/llm/schemas/plan.py +++ b/app/integrations/llm/schemas/plan.py @@ -2,6 +2,15 @@ from typing import Literal from pydantic import BaseModel +class SummarizeInput(BaseModel): + label: str + data: str + + +class SummarizeOutput(BaseModel): + summary: str + + class PlanInput(BaseModel): clinic_name: str | None = None clinic_name_en: str | None = None diff --git a/app/integrations/llm/temp-prompt/summarize_prompt.txt b/app/integrations/llm/temp-prompt/summarize_prompt.txt new file mode 100644 index 0000000..6e143ea --- /dev/null +++ b/app/integrations/llm/temp-prompt/summarize_prompt.txt @@ -0,0 +1,7 @@ +다음은 "{label}" 원본 데이터입니다. + +{data} + +위 데이터를 마케팅 플랜 생성에 필요한 핵심 정보만 남기고 간결하게 요약하세요. +구체적인 수치·날짜·고유명사(채널명, 게시물 제목 등)는 그대로 보존하고, 중복되거나 플랜 작성에 불필요한 메타데이터는 제거하세요. +요약문 하나의 문자열로만 출력하세요. diff --git a/app/services/analysis.py b/app/services/analysis.py index e640658..bd9a8f0 100644 --- a/app/services/analysis.py +++ b/app/services/analysis.py @@ -1,3 +1,4 @@ +import asyncio import json import logging from urllib.parse import urlparse @@ -7,7 +8,7 @@ from common.db.run import update_run_report, update_run_plan, select_run_report_ from common.db.source import select_run_raw_data, select_mainpage_logo_url from common.db.market import select_market from integrations.llm.llm_service import LLMService -from integrations.llm.prompt import report_prompt, plan_prompt, youtube_diagnosis_prompt, brand_consistency_prompt, critical_issues_prompt, transformation_prompt, roadmap_prompt, scores_prompt, other_channels_prompt +from integrations.llm.prompt import report_prompt, plan_prompt, summarize_prompt, youtube_diagnosis_prompt, brand_consistency_prompt, critical_issues_prompt, transformation_prompt, roadmap_prompt, scores_prompt, other_channels_prompt from integrations.llm.schemas.report import ReportOutput, ClinicSnapshot, YouTubeAudit, BrandConsistencyOutput, CriticalIssuesOutput, DiagnosisItem, TransformationProposal, RoadmapOutput, RoadmapMonth, ScoresOutput, ChannelScore, WebsiteAudit, OtherChannelsOutput, OtherChannel from services.branding import analyze_branding from services.instagram_audit import build_instagram_audit @@ -49,32 +50,52 @@ async def generate_plan(analysis_run_id: str) -> PlanOutput: def _json(v) -> str | None: return json.dumps(v, ensure_ascii=False) if v else None - 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), + # map: 큰 입력은 LLM으로 압축 요약해 100KB 초과 에러를 방지하고, 작은 입력은 그대로 둔다. + large_fields = { "report": _json(report), "market_competitors": _json(market.get("competitors")), "market_keywords": _json(market.get("keywords")), "market_trend": _json(market.get("trend")), "market_target_audience": _json(market.get("target_audience")), "tiktok": _json(tiktok), - "instagram": _json(instagram), - "facebook": _json(facebook), - "naver_blog": _json(_naver_blog_summary(naver_blog)), + "instagram": _json(instagram), + "facebook": _json(facebook), "naver_cafe": _json(naver_cafe), - "kakao_talk": _json(kakaotalk), "channel_logos": _json(branding.get("channelLogos")), "brand_assets": _json(branding.get("brandAssets")), } + summarized = dict(zip( + large_fields.keys(), + await asyncio.gather(*(_summarize(label, data) for label, data in large_fields.items())), + )) + + # reduce: 요약된 입력을 모아 최종 플랜 생성. + 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), + "naver_blog": _json(_naver_blog_summary(naver_blog)), + "kakao_talk": _json(kakaotalk), + **summarized, + } return await LLMService(provider="perplexity").generate(plan_prompt, input_data) +_SUMMARIZE_THRESHOLD = 4000 # 이 길이(문자 수)를 넘는 입력만 요약 LLM 호출 (불필요한 호출 방지) + + +async def _summarize(label: str, data: str | None) -> str | None: + if not data or len(data) <= _SUMMARIZE_THRESHOLD: + return data + result = await LLMService(provider="perplexity").generate(summarize_prompt, {"label": label, "data": data}) + return result.summary + + def _build_clinic_snapshot(mainpage: dict, gangnam_unni: dict, brand_assets: dict, logo_url: str | None) -> dict: snapshot: dict = {} doctors = gangnam_unni.get("doctors", [])