From 8beec9c0a3c5f378cf933a76300752f4b87e8a75 Mon Sep 17 00:00:00 2001 From: taeik Date: Thu, 9 Jul 2026 15:48:03 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=A7=84=EB=B3=B4=EC=84=B1=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/config.py | 2 + app/main.py | 3 +- app/routers/inventive_step.py | 230 ++++++++++ app/services/inventive_step_analyzer.py | 559 ++++++++++++++++++++++++ 4 files changed, 793 insertions(+), 1 deletion(-) create mode 100644 app/routers/inventive_step.py create mode 100644 app/services/inventive_step_analyzer.py diff --git a/app/config.py b/app/config.py index 619179c..c57025c 100644 --- a/app/config.py +++ b/app/config.py @@ -44,6 +44,8 @@ class Settings(BaseSettings): claude_novelty_model: str = "claude-haiku-4-5-20251001" + claude_inventive_step_model: str = "claude-haiku-4-5-20251001" + # ===== KIPRIS API ===== kipris_api_key: str diff --git a/app/main.py b/app/main.py index 257cc48..4a9d46c 100644 --- a/app/main.py +++ b/app/main.py @@ -11,7 +11,7 @@ from fastapi import FastAPI -from app.routers import search, components, novelty +from app.routers import search, components, novelty, inventive_step from app.services.embedding import embedding_service from app.services.opensearch_client import opensearch_service from app.services.pgvector_client import pgvector_service @@ -58,6 +58,7 @@ async def lifespan(app: FastAPI): app.include_router(search.router) app.include_router(components.router) app.include_router(novelty.router) +app.include_router(inventive_step.router) @app.get("/health") diff --git a/app/routers/inventive_step.py b/app/routers/inventive_step.py new file mode 100644 index 0000000..d23b6e2 --- /dev/null +++ b/app/routers/inventive_step.py @@ -0,0 +1,230 @@ +""" +============================================================ +진보성 분석 API 라우터 +============================================================ +5가지 엔드포인트: + 1. /analyze/inventive-step/select-secondary - D2 자동 선정 + 2. /analyze/inventive-step/generate/numerical-limit + 3. /analyze/inventive-step/generate/combination-motivation + 4. /analyze/inventive-step/generate/common-technique + 5. /analyze/inventive-step/generate/simple-design + +Spring 흐름: + - 변리사가 선행문헌함에서 주인용(D1) 선택 + "기술 진보성 분석" 클릭 + → Spring이 select-secondary 호출 → D2 자동 결정 + → inventive_step_analyses INSERT + - 각 카테고리 카드에서 "AI 자동 생성" 버튼 클릭 시 + → Spring이 해당 카테고리의 generate 엔드포인트 호출 + → inventive_arguments의 content JSONB 업데이트 +============================================================ +""" + +import logging +from typing import Optional + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +from app.services.inventive_step_analyzer import ( + InventionComponent, + PriorArtInfo, + select_secondary_art, + generate_numerical_limit, + generate_combination_motivation, + generate_common_technique, + generate_simple_design, + NumericalLimitResult, + CombinationMotivationResult, + CommonTechniqueResult, + SimpleDesignResult, +) + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/analyze/inventive-step", tags=["inventive-step"]) + + +# ============================================================ +# 1. 부인용 D2 자동 선정 +# ============================================================ + +class SelectSecondaryRequest(BaseModel): + invention_title: str + invention_description: str + components: list[InventionComponent] + primary_art: PriorArtInfo + candidates: list[PriorArtInfo] = Field(description="D2 후보 (D1 제외한 상위 N건, included=true)") + + +class SelectSecondaryResponse(BaseModel): + d2_application_number: str + + +@router.post("/select-secondary", response_model=SelectSecondaryResponse) +async def select_secondary_endpoint( + request: SelectSecondaryRequest, +) -> SelectSecondaryResponse: + """ + D1이 주어졌을 때 D2(부인용)를 후보 중에서 자동 선정. + + Raises: + 400: 후보가 비어있음 + 502: LLM 판단 실패 + """ + if not request.candidates: + raise HTTPException( + status_code=400, + detail="D2 후보가 없습니다. 선행기술 목록을 확인해주세요.", + ) + if not request.components: + raise HTTPException( + status_code=400, + detail="구성요소가 필요합니다. 먼저 구성요소를 등록해주세요.", + ) + + result = await select_secondary_art( + invention_title=request.invention_title, + invention_description=request.invention_description, + components=request.components, + primary_art=request.primary_art, + candidates=request.candidates, + ) + + if result is None: + raise HTTPException(status_code=502, detail="D2 선정에 실패했습니다.") + + # LLM이 후보에 없는 출원번호를 반환한 경우 방어 + candidate_nums = {c.application_number for c in request.candidates} + if result.d2_application_number not in candidate_nums: + logger.warning( + f"[InventiveStep] LLM이 후보에 없는 D2 반환: {result.d2_application_number}, " + f"후보={candidate_nums}. 첫 번째 후보로 대체." + ) + result.d2_application_number = request.candidates[0].application_number + + return SelectSecondaryResponse( + d2_application_number=result.d2_application_number, + ) + + +# ============================================================ +# 2. 수치한정 논리 생성 +# ============================================================ + +class GenerateNumericalLimitRequest(BaseModel): + invention_title: str + invention_description: str + primary_art: PriorArtInfo + + +@router.post("/generate/numerical-limit", response_model=NumericalLimitResult) +async def generate_numerical_limit_endpoint( + request: GenerateNumericalLimitRequest, +) -> NumericalLimitResult: + """수치한정 (발명의 효과 표) 자동 생성""" + + result = await generate_numerical_limit( + invention_title=request.invention_title, + invention_description=request.invention_description, + primary_art=request.primary_art, + ) + + if result is None: + raise HTTPException(status_code=502, detail="수치한정 논리 생성에 실패했습니다.") + + return result + + +# ============================================================ +# 3. 복수인용발명결합 논리 생성 +# ============================================================ + +class GenerateCombinationMotivationRequest(BaseModel): + invention_title: str + invention_description: str + primary_art: PriorArtInfo + secondary_art: PriorArtInfo + + +@router.post("/generate/combination-motivation", response_model=CombinationMotivationResult) +async def generate_combination_motivation_endpoint( + request: GenerateCombinationMotivationRequest, +) -> CombinationMotivationResult: + """복수인용발명결합 (Teaching Away) 논리 자동 생성""" + + result = await generate_combination_motivation( + invention_title=request.invention_title, + invention_description=request.invention_description, + primary_art=request.primary_art, + secondary_art=request.secondary_art, + ) + + if result is None: + raise HTTPException(status_code=502, detail="복수인용발명결합 논리 생성에 실패했습니다.") + + return result + + +# ============================================================ +# 4. 주지관용기술 반박 생성 +# ============================================================ + +class GenerateCommonTechniqueRequest(BaseModel): + invention_title: str + invention_description: str + components: list[InventionComponent] + primary_art: PriorArtInfo + + +@router.post("/generate/common-technique", response_model=CommonTechniqueResult) +async def generate_common_technique_endpoint( + request: GenerateCommonTechniqueRequest, +) -> CommonTechniqueResult: + """주지관용기술 반박 논리 자동 생성""" + + if not request.components: + raise HTTPException(status_code=400, detail="구성요소가 필요합니다.") + + result = await generate_common_technique( + invention_title=request.invention_title, + invention_description=request.invention_description, + components=request.components, + primary_art=request.primary_art, + ) + + if result is None: + raise HTTPException(status_code=502, detail="주지관용기술 반박 생성에 실패했습니다.") + + return result + + +# ============================================================ +# 5. 단순설계변경 논리 생성 +# ============================================================ + +class GenerateSimpleDesignRequest(BaseModel): + invention_title: str + invention_description: str + components: list[InventionComponent] + primary_art: PriorArtInfo + + +@router.post("/generate/simple-design", response_model=SimpleDesignResult) +async def generate_simple_design_endpoint( + request: GenerateSimpleDesignRequest, +) -> SimpleDesignResult: + """단순설계변경 비자명성 논리 자동 생성""" + + if not request.components: + raise HTTPException(status_code=400, detail="구성요소가 필요합니다.") + + result = await generate_simple_design( + invention_title=request.invention_title, + invention_description=request.invention_description, + components=request.components, + primary_art=request.primary_art, + ) + + if result is None: + raise HTTPException(status_code=502, detail="단순설계변경 논리 생성에 실패했습니다.") + + return result \ No newline at end of file diff --git a/app/services/inventive_step_analyzer.py b/app/services/inventive_step_analyzer.py new file mode 100644 index 0000000..d12a62a --- /dev/null +++ b/app/services/inventive_step_analyzer.py @@ -0,0 +1,559 @@ +""" +============================================================ +진보성 분석 서비스 +============================================================ +5가지 기능: + 1. select_secondary_art: 부인용(D2) 자동 선정 + 2. generate_numerical_limit: 수치한정 논리 생성 + 3. generate_combination_motivation: 복수인용발명결합 논리 생성 (Teaching Away) + 4. generate_common_technique: 주지관용기술 반박 생성 + 5. generate_simple_design: 단순설계변경 비자명성 논리 생성 + +모델: Claude Haiku (특허 도메인 판단 + 구조화된 JSON 출력) +============================================================ +""" + +import json +import logging +from typing import Optional + +import httpx +from pydantic import BaseModel, Field + +from app.config import settings + +logger = logging.getLogger(__name__) + +CLAUDE_ENDPOINT = "https://api.anthropic.com/v1/messages" + + +# ============================================================ +# 공통 모델 +# ============================================================ + +class InventionComponent(BaseModel): + """사용자 발명 구성요소""" + label: str = Field(description="A, B, C, ...") + name: str + description: str + + +class PriorArtInfo(BaseModel): + """선행기술 정보""" + application_number: str + title: str + abstract: Optional[str] = None + claims_independent: str + tech_purpose: Optional[str] = None + + +# ============================================================ +# 1. 부인용 D2 자동 선정 +# ============================================================ + +class SelectSecondaryResult(BaseModel): + d2_application_number: str + + +SELECT_SECONDARY_SYSTEM_PROMPT = """\ +당신은 특허 진보성 분석 전문가입니다. +주인용 발명(D1)이 이미 선정된 상태에서, 이와 결합하여 본 발명의 진보성을 부정할 수 있는 +가장 적절한 부인용 발명(D2)을 후보 중에서 선정하세요. + +D2 선정 기준: +1. D1이 개시하지 않은 구성요소를 개시해야 함 (D1의 부족한 부분 보완) +2. D1과 결합 가능한 기술 분야 (완전 무관한 분야는 부적합) +3. D1과 완전 동일한 관점이면 D2로 부적합 (D1이 이미 커버) + +출력 규칙: +1. JSON 형식만 출력. 마크다운, 코드 블록, 설명 텍스트 금지. +2. d2_application_number는 반드시 후보 목록의 출원번호를 그대로 사용. + +출력 형식: +{ + "d2_application_number": "후보의 출원번호" +} + +이제 JSON만 출력하세요. +""" + +SELECT_SECONDARY_USER_TEMPLATE = """\ +[사용자 발명] +명칭: {invention_title} +설명: {invention_description} + +구성요소: +{components_text} + +[주인용 D1] +출원번호: {d1_application_number} +명칭: {d1_title} +초록: +{d1_abstract} +독립 청구항: +{d1_claims} + +[부인용 후보] +{candidates_text} + +위 후보 중 부인용 D2로 가장 적합한 것을 선정하여 JSON으로 반환하세요. +""" + + +async def select_secondary_art( + invention_title: str, + invention_description: str, + components: list[InventionComponent], + primary_art: PriorArtInfo, + candidates: list[PriorArtInfo], +) -> Optional[SelectSecondaryResult]: + """D1이 주어졌을 때 후보들 중 D2 자동 선정""" + + if not candidates: + logger.warning("[InventiveStep] D2 후보가 없음") + return None + + # 구성요소 텍스트 + components_text = "\n".join([ + f"{c.label}. {c.name}: {c.description}" for c in components + ]) + + # 후보 텍스트 (초록 포함) + candidates_text = "\n\n".join([ + f"[후보 {i + 1}]\n" + f"출원번호: {c.application_number}\n" + f"명칭: {c.title}\n" + f"초록:\n{c.abstract or '(정보 없음)'}\n" + f"기술목적: {c.tech_purpose or '(정보 없음)'}\n" + f"독립 청구항:\n{c.claims_independent}" + for i, c in enumerate(candidates) + ]) + + user_message = SELECT_SECONDARY_USER_TEMPLATE.format( + invention_title=invention_title, + invention_description=invention_description, + components_text=components_text, + d1_application_number=primary_art.application_number, + d1_title=primary_art.title, + d1_abstract=primary_art.abstract or "(정보 없음)", + d1_claims=primary_art.claims_independent, + candidates_text=candidates_text, + ) + + return await _call_claude_with_json( + system_prompt=SELECT_SECONDARY_SYSTEM_PROMPT, + user_message=user_message, + result_model=SelectSecondaryResult, + log_prefix="[InventiveStep/SelectSecondary]", + ) + + +# ============================================================ +# 2. 수치한정 (효과의 현저성) +# ============================================================ + +class EffectItem(BaseModel): + """발명의 효과 항목 1개""" + metric: str = Field(description="측정 지표 (예: VOC 배출량)") + unit: str = Field(description="단위 (예: g/L, %)") + prior_art_value: str = Field(description="종래기술 수치") + invention_value: str = Field(description="본 발명 수치") + improvement: str = Field(description="개선률 (예: 97.5%)") + + +class NumericalLimitResult(BaseModel): + effect_items: list[EffectItem] = Field(description="발명의 효과 표 항목들 (3~5개)") + + +NUMERICAL_LIMIT_SYSTEM_PROMPT = """\ +당신은 특허 수치한정 발명의 효과 분석 전문가입니다. +본 발명이 종래기술 대비 어떤 수치적 효과를 가지는지 표 형태로 정리하세요. + +출력 규칙: +1. JSON 형식만 출력. 마크다운, 코드 블록, 설명 텍스트 금지. +2. 한국어로 작성. +3. **본 발명 설명이나 D1 청구항에 명시된 수치만 사용**. 명시되지 않은 수치를 추정하거나 창작하지 마세요. +4. 명시된 수치가 하나도 없으면 빈 배열을 반환하세요. +5. 명시된 수치가 있는 항목만 반환. 개수 목표를 채우기 위해 무리하게 항목을 만들지 마세요 (0~5개 유동적). +6. improvement는 두 수치가 모두 있을 때만 계산. 계산 불가면 해당 항목 자체를 반환하지 마세요. + +각 필드: +- metric: 측정 지표 명 (본문에 명시된 것 우선. 예: "VOC 배출량", "회수율") +- unit: 단위 (본문에서 확인. 예: "g/L", "%") +- prior_art_value: 종래기술 수치 (D1에 명시된 값만, 숫자 문자열) +- invention_value: 본 발명 수치 (본 발명 설명에 명시된 값만, 숫자 문자열) +- improvement: 개선률 (두 수치로 계산. 예: "97.5%", "3배 감소") + +출력 형식: +{ + "effect_items": [ + { + "metric": "VOC 배출량", + "unit": "g/L", + "prior_art_value": "320", + "invention_value": "8", + "improvement": "97.5%" + } + ] +} + +명시된 수치가 없으면: +{ + "effect_items": [] +} + +이제 JSON만 출력하세요. +""" + +NUMERICAL_LIMIT_USER_TEMPLATE = """\ +[사용자 발명] +명칭: {invention_title} +설명: {invention_description} + +[주인용 D1] +명칭: {d1_title} +초록: +{d1_abstract} +독립 청구항: +{d1_claims} + +위 본 발명이 D1(종래기술) 대비 갖는 수치적 효과를 표 형태로 정리하여 JSON으로 반환하세요. +""" + + +async def generate_numerical_limit( + invention_title: str, + invention_description: str, + primary_art: PriorArtInfo, +) -> Optional[NumericalLimitResult]: + """수치한정 논리 (발명의 효과 표) 자동 생성""" + + user_message = NUMERICAL_LIMIT_USER_TEMPLATE.format( + invention_title=invention_title, + invention_description=invention_description, + d1_title=primary_art.title, + d1_abstract=primary_art.abstract or "(정보 없음)", + d1_claims=primary_art.claims_independent, + ) + + return await _call_claude_with_json( + system_prompt=NUMERICAL_LIMIT_SYSTEM_PROMPT, + user_message=user_message, + result_model=NumericalLimitResult, + log_prefix="[InventiveStep/NumericalLimit]", + ) + + +# ============================================================ +# 3. 복수인용발명결합 (Teaching Away) +# ============================================================ + +class CombinationMotivationResult(BaseModel): + background_limit: str = Field(description="배경기술의 한계 (100~200자)") + teaching_away: str = Field(description="결합 동기의 부재 (100~200자)") + + +COMBINATION_MOTIVATION_SYSTEM_PROMPT = """\ +당신은 특허 진보성 분석 중 복수인용발명결합(Teaching Away) 논리 전문가입니다. +D1과 D2를 결합하여 본 발명에 도달할 동기가 없음을 논증하세요. + +출력 규칙: +1. JSON 형식만 출력. 마크다운, 코드 블록, 설명 텍스트 금지. +2. 한국어로 작성. +3. 각 필드는 100~200자. + +각 필드 작성 가이드: + +- background_limit: 종래기술의 한계를 본 발명의 해결 방향과 반대로 정리. + "종래기술은 X 방향으로 발전해왔으나, 본 발명은 반대인 Y 방향을 채택함..." + +- teaching_away: D1과 D2가 서로 반대 방향으로 가르쳐 결합 동기가 없음을 논증. + "D1은 A 방식을 개시하고 D2는 B 방식을 개시하나, 두 방식은 상충되어..." + +출력 형식: +{ + "background_limit": "...", + "teaching_away": "..." +} + +이제 JSON만 출력하세요. +""" + +COMBINATION_MOTIVATION_USER_TEMPLATE = """\ +[사용자 발명] +명칭: {invention_title} +설명: {invention_description} + +[주인용 D1] +명칭: {d1_title} +초록: +{d1_abstract} +독립 청구항: +{d1_claims} + +[부인용 D2] +명칭: {d2_title} +초록: +{d2_abstract} +독립 청구항: +{d2_claims} + +D1과 D2 결합의 동기가 없음을 논증하여 JSON으로 반환하세요. +""" + + +async def generate_combination_motivation( + invention_title: str, + invention_description: str, + primary_art: PriorArtInfo, + secondary_art: PriorArtInfo, +) -> Optional[CombinationMotivationResult]: + """복수인용발명결합 Teaching Away 논리 자동 생성""" + + user_message = COMBINATION_MOTIVATION_USER_TEMPLATE.format( + invention_title=invention_title, + invention_description=invention_description, + d1_title=primary_art.title, + d1_abstract=primary_art.abstract or "(정보 없음)", + d1_claims=primary_art.claims_independent, + d2_title=secondary_art.title, + d2_abstract=secondary_art.abstract or "(정보 없음)", + d2_claims=secondary_art.claims_independent, + ) + + return await _call_claude_with_json( + system_prompt=COMBINATION_MOTIVATION_SYSTEM_PROMPT, + user_message=user_message, + result_model=CombinationMotivationResult, + log_prefix="[InventiveStep/CombinationMotivation]", + ) + + +# ============================================================ +# 4. 주지관용기술 반박 +# ============================================================ + +class CommonTechniqueResult(BaseModel): + target_component: str = Field(description="주지관용기술로 지목되는 구성요소 라벨 (A/B/C/...)") + rebuttal: str = Field(description="반박 논리 (150~250자)") + + +COMMON_TECHNIQUE_SYSTEM_PROMPT = """\ +당신은 특허 진보성 분석 중 주지관용기술 반박 논리 전문가입니다. +심사관이 본 발명의 특정 구성요소를 "주지관용기술"로 판단할 가능성이 있는 경우, +그것이 주지관용기술이 아니라는 반박 논리를 작성합니다. + +출력 규칙: +1. JSON 형식만 출력. 마크다운, 코드 블록, 설명 텍스트 금지. +2. 한국어로 작성. +3. target_component는 사용자 구성요소 중 가장 반박이 필요한 하나의 라벨. +4. rebuttal은 150~250자. + +target_component 선정 기준: +- D1에 개시되지 않은 구성요소 중 하나 +- 심사관이 "이건 이 기술 분야에서 흔한 기술"이라 판단할 가능성이 있는 것 + +rebuttal 작성 가이드: +- 왜 주지관용기술이 아닌지 근거 제시 +- 해당 구성요소의 독창성, 특별한 기능, 진보된 효과 강조 +- 단순히 "관용기술 아니다" 주장 말고 구체적 논거 포함 + +출력 형식: +{ + "target_component": "B", + "rebuttal": "..." +} + +이제 JSON만 출력하세요. +""" + +COMMON_TECHNIQUE_USER_TEMPLATE = """\ +[사용자 발명] +명칭: {invention_title} +설명: {invention_description} + +구성요소: +{components_text} + +[주인용 D1] +명칭: {d1_title} +초록: +{d1_abstract} +독립 청구항: +{d1_claims} + +위 구성요소 중 심사관이 "주지관용기술"로 판단할 가능성이 높은 하나를 선정하고 +그것이 주지관용기술이 아니라는 반박 논리를 JSON으로 반환하세요. +""" + + +async def generate_common_technique( + invention_title: str, + invention_description: str, + components: list[InventionComponent], + primary_art: PriorArtInfo, +) -> Optional[CommonTechniqueResult]: + """주지관용기술 반박 논리 자동 생성""" + + components_text = "\n".join([ + f"{c.label}. {c.name}: {c.description}" for c in components + ]) + + user_message = COMMON_TECHNIQUE_USER_TEMPLATE.format( + invention_title=invention_title, + invention_description=invention_description, + components_text=components_text, + d1_title=primary_art.title, + d1_abstract=primary_art.abstract or "(정보 없음)", + d1_claims=primary_art.claims_independent, + ) + + return await _call_claude_with_json( + system_prompt=COMMON_TECHNIQUE_SYSTEM_PROMPT, + user_message=user_message, + result_model=CommonTechniqueResult, + log_prefix="[InventiveStep/CommonTechnique]", + ) + + +# ============================================================ +# 5. 단순설계변경 (비자명성 논리) +# ============================================================ + +class SimpleDesignResult(BaseModel): + changed_component: str = Field(description="변경된 구성요소 라벨 (A/B/C/...)") + non_obviousness: str = Field(description="단순 설계 변경이 아니라는 논리 (150~250자)") + + +SIMPLE_DESIGN_SYSTEM_PROMPT = """\ +당신은 특허 진보성 분석 중 단순설계변경 반박 논리 전문가입니다. +심사관이 본 발명을 D1의 "단순 설계 변경"으로 판단할 가능성이 있는 경우, +그것이 단순 변경이 아니라 비자명한 개선이라는 논리를 작성합니다. + +출력 규칙: +1. JSON 형식만 출력. 마크다운, 코드 블록, 설명 텍스트 금지. +2. 한국어로 작성. +3. changed_component는 사용자 구성요소 중 가장 반박이 필요한 하나의 라벨. +4. non_obviousness는 150~250자. + +changed_component 선정 기준: +- D1과의 차이점이 있는 구성요소 중 하나 +- 심사관이 "이건 D1을 조금만 바꾼 것"이라 판단할 가능성이 있는 것 + +non_obviousness 작성 가이드: +- 왜 단순 변경이 아닌지 근거 제시 +- 통상의 기술자가 D1으로부터 쉽게 도출할 수 없다는 논거 +- 예상치 못한 효과, 기술적 극복 요소, 결합의 진보성 등 언급 + +출력 형식: +{ + "changed_component": "C", + "non_obviousness": "..." +} + +이제 JSON만 출력하세요. +""" + +SIMPLE_DESIGN_USER_TEMPLATE = """\ +[사용자 발명] +명칭: {invention_title} +설명: {invention_description} + +구성요소: +{components_text} + +[주인용 D1] +명칭: {d1_title} +초록: +{d1_abstract} +독립 청구항: +{d1_claims} + +위 구성요소 중 심사관이 D1의 "단순 설계 변경"으로 판단할 가능성이 높은 하나를 선정하고 +그것이 비자명한 개선이라는 논리를 JSON으로 반환하세요. +""" + + +async def generate_simple_design( + invention_title: str, + invention_description: str, + components: list[InventionComponent], + primary_art: PriorArtInfo, +) -> Optional[SimpleDesignResult]: + """단순설계변경 비자명성 논리 자동 생성""" + + components_text = "\n".join([ + f"{c.label}. {c.name}: {c.description}" for c in components + ]) + + user_message = SIMPLE_DESIGN_USER_TEMPLATE.format( + invention_title=invention_title, + invention_description=invention_description, + components_text=components_text, + d1_title=primary_art.title, + d1_abstract=primary_art.abstract or "(정보 없음)", + d1_claims=primary_art.claims_independent, + ) + + return await _call_claude_with_json( + system_prompt=SIMPLE_DESIGN_SYSTEM_PROMPT, + user_message=user_message, + result_model=SimpleDesignResult, + log_prefix="[InventiveStep/SimpleDesign]", + ) + + +# ============================================================ +# 공통 Claude 호출 헬퍼 +# ============================================================ + +async def _call_claude_with_json( + system_prompt: str, + user_message: str, + result_model: type[BaseModel], + log_prefix: str, +) -> Optional[BaseModel]: + """ + Claude 호출 → JSON 파싱 → Pydantic 모델 검증 → 반환. + 실패 시 None 반환 (에러는 로그로). + """ + payload = { + "model": settings.claude_inventive_step_model, + "max_tokens": 2048, + "temperature": 0.3, + "system": system_prompt, + "messages": [{"role": "user", "content": user_message}], + } + + headers = { + "x-api-key": settings.claude_api_key, + "anthropic-version": "2023-06-01", + "content-type": "application/json", + } + + async with httpx.AsyncClient(timeout=60.0) as client: + try: + response = await client.post(CLAUDE_ENDPOINT, headers=headers, json=payload) + response.raise_for_status() + data = response.json() + except httpx.HTTPError: + logger.exception(f"{log_prefix} Claude API 호출 실패") + return None + + try: + text = data["content"][0]["text"].strip() + except (KeyError, IndexError): + logger.error(f"{log_prefix} Claude 응답 구조 이상: {data}") + return None + + try: + parsed = json.loads(text) + except json.JSONDecodeError: + logger.error(f"{log_prefix} JSON 파싱 실패: {text[:300]}") + return None + + try: + return result_model(**parsed) + except Exception as e: + logger.error(f"{log_prefix} 결과 검증 실패: {e}, parsed={parsed}") + return None \ No newline at end of file