Phase W + V' 완료: before→filled→after 파이프라인 + 조립 로직 수정

Phase W:
- weight 비율 초기 배정 (space_allocator header 높이 반영)
- block_assembler 공통 조립 함수 (filled/assembled 통합)
- filled → Selenium 측정 → context 저장
- sidebar overflow 확장 + body 재배분
- sub_layouts 사전 계산 (이미지 누락 해결)

Phase V':
- 팝업 링크 우측상단 배치 (인라인 → position:absolute)
- 표 내용 Kei 판단 (공란 크기 계산 → 행/열 산출 → Kei 요약)
- 출처 라벨 삭제 + 이미지 아래 캡션 배치
- after 공란 제거 (결론 바로 위까지 body/sidebar 채움)

추가:
- V-10 bold 키워드: 기계적 추출 → Kei 문맥 판단
- ** 마크다운 → <strong> 변환
- [이미지:] 마커 제거 (bold 변환 전 처리)
- grid-template-rows AFTER 크기 반영 (Sonnet final)
- assemble_stage2 CSS font-size override, white-space fix
- 하드코딩 전수 검토 완료
- 본심 여러 topic 텍스트 합침

Phase X 계획 문서 작성 (동적 역할 구조)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 05:00:52 +09:00
parent 24eb1bc5ad
commit 1f7579cf64
64 changed files with 13955 additions and 696 deletions

View File

@@ -116,18 +116,22 @@ KEI_PROMPT_B = (
" - 예: '기술 융합/포함 관계. 순서 아님. 구성요소 간 관계 표현 필요.'\n"
" - 예: '수단-목적 관계. 대등 비교가 아님. 역할 차이를 보여줘야 함.'\n"
" - 예: '용어 3개의 독립적 정의. 나열.'\n\n"
"3. **원본 데이터 확인 (source_data)**:\n"
" - 원본에 비교표가 있는가? → 행/열 수, 활용 여부\n"
" - 원본에 사례/증거가 있는가? → 출처 명시\n"
" - 원본에 이미지가 있는가? → 역할\n"
" - 놓치면 안 되는 핵심 데이터가 있는가?\n\n"
"3. **원본 데이터 핵심 항목 (source_data)**:\n"
" - 이 꼭지에 해당하는 원본의 핵심 항목들을 나열하라.\n"
" - 항목이 여러 개면 '이름(설명), 이름(설명)' 형태로 쉼표 구분.\n"
" - 원본에 팝업이 참조되면 반드시 [팝업: 제목] 마커를 포함하라.\n"
" - 원본에 이미지가 참조되면 반드시 [이미지: 제목] 마커를 포함하라.\n"
" - 출처가 있으면 포함하라.\n"
" - '활용 필요', '구체화 필요' 같은 지시사항을 쓰지 마라. 실제 콘텐츠 항목만 쓰라.\n"
" - 예시: '건설산업(종합산업, 기술 통합 융합), BIM(정보관리 도구, 출처: 국토교통부 2020)'\n"
" - 예시: '[이미지: DX와 핵심기술간 상호관계] 다이어그램, GIS 역할(공간 분석). [팝업: DX와 BIM의 구분] 비교표'\n\n"
"## 출력 형식 (JSON만)\n"
"```json\n"
'{"concepts": ['
'{"topic_id": 1, '
'"relation_type": "inclusion|sequence|comparison|hierarchy|definition|cause_effect|none", '
'"expression_hint": "관계 성격 설명 (블록 이름 쓰지 말 것)", '
'"source_data": "원본에서 활용해야 할 데이터 설명"}]}\n'
'"source_data": "핵심 항목 나열 + [팝업:] [이미지:] 마커 + 출처"}]}\n'
"```\n\n"
)
@@ -173,11 +177,9 @@ async def refine_concepts(
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream(
"POST",
f"{kei_url}/api/message",
f"{kei_url}/api/direct",
json={
"message": prompt,
"session_id": "design-agent-refine",
"mode_hint": "chat",
},
timeout=None,
) as response:
@@ -222,6 +224,104 @@ async def refine_concepts(
continue
KEI_STRUCTURED_TEXT_PROMPT = (
"아래는 슬라이드 스토리라인의 꼭지 목록과 원본 콘텐츠이다.\n"
"각 꼭지에 해당하는 원본 텍스트를 **슬라이드에 넣을 형태로 구조화**하라.\n\n"
"## 규칙\n"
"1. 원본 내용의 85% 이상을 보존하라. 축약하지 마라.\n"
"2. 각 문장을 불릿(•)으로 구분하라.\n"
"3. 하위 항목이 있으면 들여쓰기 불릿( •)으로 구분하라.\n"
"4. 출처가 있으면 반드시 포함하라 (출처: ...).\n"
"5. 개조식 어미로 변환하라 (~있다→~있음, ~한다→~함, ~이다→삭제).\n"
"6. 팝업 참조([팝업: ...])는 그대로 유지하라.\n"
"7. 이미지 참조([이미지: ...])는 그대로 유지하라.\n\n"
"## 출력 형식 (JSON만. 설명 없이.)\n"
"```json\n"
'{"structured_texts": ['
'{"topic_id": 1, '
'"structured_text": "• 첫 번째 문장\\n• 두 번째 문장\\n • 하위 항목"}]}\n'
"```\n\n"
)
async def generate_structured_text(
content: str,
analysis: dict[str, Any],
) -> dict[str, Any]:
"""1단계-B 보완: 각 꼭지의 structured_text를 생성.
refine_concepts() 후 별도 호출. 원본 텍스트를 85% 보존하여 구조화.
"""
import asyncio
topics = analysis.get("topics", [])
if not topics:
return analysis
topics_text = "\n".join(
f"- 꼭지 {t.get('id', '?')}: {t.get('title', '?')} "
f"[purpose: {t.get('purpose', '?')}, source_hint: {t.get('source_hint', '')}]"
for t in topics
)
prompt = (
KEI_STRUCTURED_TEXT_PROMPT
+ f"## 꼭지 목록\n{topics_text}\n\n"
+ f"## 원본 콘텐츠\n{content}\n"
)
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
RETRY_INTERVAL = 10
attempt = 0
while True:
attempt += 1
try:
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream(
"POST",
f"{kei_url}/api/direct",
json={"message": prompt},
timeout=None,
) as response:
if response.status_code != 200:
logger.warning(f"[1단계-B-ST] Kei API HTTP {response.status_code} (시도 {attempt})")
await asyncio.sleep(RETRY_INTERVAL)
continue
full_text = await stream_sse_tokens(response)
if not full_text:
logger.warning(f"[1단계-B-ST] 응답 텍스트 없음 (시도 {attempt})")
await asyncio.sleep(RETRY_INTERVAL)
continue
result = _parse_json(full_text)
if result and "structured_texts" in result:
st_map = {}
for st in result["structured_texts"]:
tid = st.get("topic_id") or st.get("id")
if tid is not None:
st_map[tid] = st.get("structured_text", "")
for topic in topics:
text = st_map.get(topic.get("id"), "")
if text:
topic["structured_text"] = text
filled = sum(1 for t in topics if t.get("structured_text"))
logger.info(f"[1단계-B-ST] structured_text 생성 완료: {filled}/{len(topics)}")
return analysis
else:
logger.warning(f"[1단계-B-ST] JSON 파싱 실패 (시도 {attempt}): {full_text[:200]}")
await asyncio.sleep(RETRY_INTERVAL)
continue
except Exception as e:
logger.warning(f"[1단계-B-ST] Kei API 실패 (시도 {attempt}): {e}")
await asyncio.sleep(RETRY_INTERVAL)
continue
async def _call_kei_api(content: str) -> dict[str, Any] | None:
"""Kei API를 통해 꼭지 추출. SSE 스트리밍으로 실시간 수신."""
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
@@ -230,11 +330,9 @@ async def _call_kei_api(content: str) -> dict[str, Any] | None:
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream(
"POST",
f"{kei_url}/api/message",
f"{kei_url}/api/direct",
json={
"message": KEI_PROMPT + content,
"session_id": "design-agent",
"mode_hint": "chat",
},
timeout=None,
) as response:
@@ -324,7 +422,7 @@ async def select_block_for_topics(
continue
spec = find_container_for_topic(tid, container_specs)
per_topic_px = spec.height_px // max(1, len(spec.topic_ids)) if spec else 200
per_topic_px = spec.height_px // max(1, len(spec.topic_ids)) if spec else 0
budget = budgets_per_topic.get(tid, {})
expression_hint = topic.get("expression_hint", "")
@@ -347,11 +445,9 @@ async def select_block_for_topics(
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream(
"POST",
f"{kei_url}/api/message",
f"{kei_url}/api/direct",
json={
"message": full_prompt,
"session_id": "design-agent-q4",
"mode_hint": "chat",
},
timeout=None,
) as response:
@@ -450,7 +546,7 @@ async def vision_quality_gate(
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
response = await client.messages.create(
model="claude-opus-4-0-20250514",
model="claude-opus-4-6-20250415",
max_tokens=2048,
messages=[{
"role": "user",
@@ -568,7 +664,7 @@ async def call_kei_final_review(
try:
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
response = await client.messages.create(
model="claude-opus-4-0-20250514",
model="claude-opus-4-6-20250415",
max_tokens=4096,
system=KEI_REVIEW_PROMPT,
messages=[{
@@ -615,11 +711,9 @@ async def call_kei_final_review(
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream(
"POST",
f"{kei_url}/api/message",
f"{kei_url}/api/direct",
json={
"message": prompt,
"session_id": "design-agent-final-review",
"mode_hint": "chat",
},
timeout=None,
) as response:
@@ -717,11 +811,9 @@ async def call_kei_overflow_judgment(
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream(
"POST",
f"{kei_url}/api/message",
f"{kei_url}/api/direct",
json={
"message": prompt,
"session_id": "design-agent-overflow",
"mode_hint": "chat",
},
timeout=None,
) as response:
@@ -870,7 +962,7 @@ async def select_best_candidate(
try:
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
response = await client.messages.create(
model="claude-opus-4-0-20250514",
model="claude-opus-4-6-20250415",
max_tokens=2048,
messages=[{"role": "user", "content": content_blocks}],
)
@@ -890,3 +982,435 @@ async def select_best_candidate(
except Exception as e:
logger.warning(f"[Phase P] Kei 최종 선택 실패: {e}")
return {"selections": []}
# ──────────────────────────────────────
# Phase V: 콘텐츠-컨테이너 적합성 에스컬레이션
# ──────────────────────────────────────
KEI_ENHANCEMENT_REVIEW_PROMPT = """당신은 슬라이드 설계 전문가이다.
아래는 슬라이드 콘텐츠 품질 강화를 위한 제안 목록이다.
각 제안을 검토하고, 승인/수정/거부를 결정하라.
## 판단 기준
- 핵심 메시지가 시각적으로 강조되는가?
- 빈 공간에 유의미한 콘텐츠를 추가할 수 있는가?
- 종속 꼭지의 처리 방식(인라인/하위블록)이 적절한가?
- bold 키워드가 핵심 용어인가?
## 출력 (JSON만. 설명 없이.)
```json
{
"decisions": [
{
"type": "subordinate|fill_space|emphasis|bold_keywords",
"role": "배경|본심|첨부|결론",
"action": "approve|modify|reject",
"modification": "수정 시 구체적 내용 (approve/reject면 빈 문자열)"
}
]
}
```
"""
async def call_kei_enhancement_review(
enhancement_report: str,
topics: list[dict],
core_message: str,
) -> dict[str, Any] | None:
"""Stage 1.8 Step 5: Kei에게 보강 제안을 보여주고 승인/수정/거부 결정을 받는다.
Kei API(/api/direct)만 사용.
"""
import asyncio
topics_text = "\n".join(
f"- 꼭지{t.get('id', '?')}: {t.get('title', '')} [{t.get('purpose', '')}]"
for t in topics
)
prompt = (
KEI_ENHANCEMENT_REVIEW_PROMPT + "\n\n"
f"## 핵심 메시지\n{core_message}\n\n"
f"## 꼭지 목록\n{topics_text}\n\n"
f"## 보강 제안\n{enhancement_report}\n"
)
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
RETRY_INTERVAL = 10
attempt = 0
while True:
attempt += 1
try:
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream(
"POST",
f"{kei_url}/api/direct",
json={"message": prompt},
timeout=None,
) as response:
if response.status_code != 200:
logger.warning(f"[V-5 Kei 보강 검토] HTTP {response.status_code} (시도 {attempt})")
await asyncio.sleep(RETRY_INTERVAL)
continue
full_text = await stream_sse_tokens(response)
if not full_text:
logger.warning(f"[V-5 Kei 보강 검토] 응답 없음 (시도 {attempt})")
await asyncio.sleep(RETRY_INTERVAL)
continue
result = _parse_json(full_text)
if result and "decisions" in result:
approved = sum(1 for d in result["decisions"] if d.get("action") == "approve")
total = len(result["decisions"])
logger.info(f"[V-5 Kei 보강 검토] {approved}/{total} 승인")
return result
else:
logger.warning(f"[V-5 Kei 보강 검토] JSON 파싱 실패 (시도 {attempt})")
await asyncio.sleep(RETRY_INTERVAL)
continue
except Exception as e:
logger.warning(f"[V-5 Kei 보강 검토] 실패 (시도 {attempt}): {e}")
await asyncio.sleep(RETRY_INTERVAL)
continue
async def call_kei_summarize_popup(
popup_title: str,
popup_content: str,
available_width_px: float,
available_height_px: float,
font_size: float,
) -> dict[str, Any] | None:
"""V'-2: 코드가 형태+크기를 결정하고, Kei가 텍스트만 채운다.
1. 코드: 팝업 원본에 표(|)가 있으면 table, 불릿(•)이면 bullets, 그 외 text
2. 코드: 공간 크기와 폰트를 고려하여 행/열 수 계산
3. Kei: 결정된 형태+크기에 맞게 원본 내용을 요약
Returns:
{
"format": "table" | "bullets" | "text",
"columns": [...], "data": [["", ...], ...], # table
"items": ["...", ...], # bullets
"summary": "...", # text
}
"""
import asyncio
header_h = font_size * 1.5 + font_size * 0.6
row_h = font_size * 1.5 + font_size * 0.6
bullet_h = font_size * 1.55
chars_per_col = int(available_width_px / (font_size * 0.6))
# 코드가 형태 판단
import re
has_table = popup_content.count("|") > 6 or "<table" in popup_content
has_bullets = popup_content.count("") > 2
if has_table:
# 원본 표에서 열 수 추출 — <th> 태그 우선, 없으면 | 파싱
th_headers = re.findall(r'<th[^>]*>(.*?)</th>', popup_content)
# <strong> 등 태그 제거
th_headers = [re.sub(r'<[^>]+>', '', h).strip() for h in th_headers]
if th_headers:
# 첫 번째 <thead>의 열만 사용 (중복 테이블 헤더 제거)
orig_cols = th_headers[:3] if len(th_headers) > 3 else th_headers
col_count = len(orig_cols)
else:
table_lines = [l.strip() for l in popup_content.split("\n") if l.strip().startswith("|")]
if len(table_lines) >= 2:
orig_cols = [c.strip() for c in table_lines[0].split("|") if c.strip()]
col_count = len(orig_cols)
else:
orig_cols = []
col_count = 3
# 행 수: 공간에 맞게 계산 (제목행 제외, 데이터 행만)
space_rows = int((available_height_px - header_h) / row_h) if available_height_px > header_h else 1
# 원본 표 데이터 행 수
orig_data_rows = len(re.findall(r'<tr>', popup_content)) or len([l for l in popup_content.split("\n") if l.strip().startswith("|") and not l.strip().startswith("|--")]) - 1
orig_data_rows = max(1, orig_data_rows)
# 공간과 원본 중 작은 쪽, 최소 1 최대 5
max_rows = min(space_rows, orig_data_rows, 5)
max_rows = max(1, max_rows)
chars_per_col = int(available_width_px / col_count / (font_size * 0.6))
fmt = "table"
prompt_task = (
f"원본 표를 정확히 {col_count}× {max_rows}행으로 요약하라.\n"
f"열 이름: {orig_cols}\n"
f"data 배열에 정확히 {max_rows}개의 행을 넣어라. 1행만 넣지 마라.\n"
f"원본에서 가장 핵심적인 {max_rows}개 비교 항목(범위, S/W, 프로세스, 성과품, 활용 등)을 골라라.\n"
f"각 셀은 {chars_per_col}자 이내의 짧은 핵심 요약으로.\n"
f"JSON: {{\"columns\": [\"{orig_cols[0] if orig_cols else '열1'}\", ...], \"data\": [[\"\", ...], ...]}}"
)
elif has_bullets:
max_items = int(available_height_px / bullet_h)
max_items = max(1, max_items)
fmt = "bullets"
prompt_task = (
f"원본 불릿을 {max_items}개 이내로 요약하라.\n"
f"각 항목은 {chars_per_col}자 이내로.\n"
f"JSON: {{\"items\": [\"항목1\", ...]}}"
)
else:
max_lines = int(available_height_px / bullet_h)
fmt = "text"
prompt_task = (
f"원본을 {max_lines}줄 이내로 요약하라.\n"
f"JSON: {{\"summary\": \"요약 텍스트\"}}"
)
prompt = f"""당신은 슬라이드 콘텐츠 요약 전문가이다.
## 팝업 제목: {popup_title}
## 팝업 원본:
{popup_content}
## 요청
{prompt_task}
핵심만 남기되, 원본의 의미가 왜곡되지 않도록 하라. JSON만 응답하라."""
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
attempt = 0
while attempt < 5:
attempt += 1
try:
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream(
"POST",
f"{kei_url}/api/direct",
json={"message": prompt},
timeout=None,
) as response:
if response.status_code != 200:
logger.warning(f"[V'-2 Kei 요약] HTTP {response.status_code} (시도 {attempt})")
await asyncio.sleep(10)
continue
full_text = await stream_sse_tokens(response)
if not full_text:
await asyncio.sleep(10)
continue
result = _parse_json(full_text)
if result and isinstance(result, dict):
# 코드가 결정한 format을 주입 (Kei는 텍스트만 채움)
result["format"] = fmt
logger.info(f"[V'-2 Kei 요약] {popup_title}: format={fmt}")
return result
else:
logger.warning(f"[V'-2 Kei 요약] 파싱 실패 (시도 {attempt})")
await asyncio.sleep(10)
continue
except Exception as e:
logger.warning(f"[V'-2 Kei 요약] 실패 (시도 {attempt}): {e}")
await asyncio.sleep(10)
continue
return None
async def call_kei_bold_keywords(
topics: list[dict],
page_structure: dict,
) -> dict[str, list[str]]:
"""V-10: Kei가 문맥 기반으로 각 역할의 bold 키워드를 판단한다.
Returns:
{"배경": ["키워드1", ...], "본심": [...], ...}
"""
import asyncio
# 역할별 structured_text 정리
topic_map = {t.get("id"): t for t in topics}
role_texts = {}
for role, info in page_structure.items():
if not isinstance(info, dict):
continue
tids = info.get("topic_ids", [])
texts = []
for tid in tids:
topic = topic_map.get(tid, {})
st = topic.get("structured_text", "") or topic.get("source_data", "")
if st:
texts.append(f"[{topic.get('title', '')}]\n{st}")
if texts:
role_texts[role] = "\n".join(texts)
if not role_texts:
return {}
role_section = "\n\n".join(
f"## {role}\n{text}" for role, text in role_texts.items()
)
prompt = f"""당신은 슬라이드 디자인 전문가이다.
아래는 슬라이드의 각 영역별 콘텐츠이다. 각 영역에서 **문맥상 정말 강조되어야 할 키워드**를 골라라.
규칙:
- 개수를 정하지 마라. 문맥에 맞게 필요한 만큼만 골라라.
- 단순 명사 나열이 아니라, 읽는 사람이 "이것이 핵심이구나"라고 느낄 키워드여야 한다.
- 일반적인 단어(역할, 기술, 정의 등)는 강조 대상이 아니다.
- 고유명사, 핵심 개념명, 대비되는 용어 등이 강조 대상이다.
JSON으로 응답하라:
{{"배경": ["키워드1", ...], "본심": [...], "첨부": [...], "결론": [...]}}
빈 역할은 빈 리스트로.
{role_section}"""
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
RETRY_INTERVAL = 10
attempt = 0
while attempt < 5:
attempt += 1
try:
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream(
"POST",
f"{kei_url}/api/direct",
json={"message": prompt},
timeout=None,
) as response:
if response.status_code != 200:
logger.warning(f"[V-10 Kei bold] HTTP {response.status_code} (시도 {attempt})")
await asyncio.sleep(RETRY_INTERVAL)
continue
full_text = await stream_sse_tokens(response)
if not full_text:
await asyncio.sleep(RETRY_INTERVAL)
continue
result = _parse_json(full_text)
if result and isinstance(result, dict):
logger.info(f"[V-10 Kei bold] 결과: {result}")
return result
else:
logger.warning(f"[V-10 Kei bold] 파싱 실패 (시도 {attempt})")
await asyncio.sleep(RETRY_INTERVAL)
continue
except Exception as e:
logger.warning(f"[V-10 Kei bold] 실패 (시도 {attempt}): {e}")
await asyncio.sleep(RETRY_INTERVAL)
continue
logger.warning("[V-10 Kei bold] 최대 재시도 초과, 빈 결과 반환")
return {}
KEI_FIT_ESCALATION_PROMPT = """당신은 슬라이드 설계 전문가이다.
콘텐츠를 컨테이너에 배치하려 했으나, 일부 영역의 콘텐츠가 공간을 초과한다.
재배분을 시도했지만 해결되지 않은 영역이 있다.
콘텐츠의 중요도와 전달 메시지를 기준으로, 어떻게 처리할지 결정하라.
## 판단 기준
- 핵심 메시지(본심)의 공간은 최대한 보장
- 배경은 보조 역할 — 간결화 가능
- 사례/근거는 인라인 축약 또는 팝업 분리 가능
- 용어 정의는 sidebar에 맞게 조정 가능
## 출력 (JSON만. 설명 없이.)
```json
{
"decisions": [
{
"role": "배경",
"action": "merge|inline|popup|trim|restructure",
"detail": "구체적 지시 (어떤 꼭지를 어떻게)",
"reason": "판단 근거 1문장"
}
]
}
```
action 종류:
- merge: 여러 꼭지를 하나의 블록 안에서 흐름으로 합침
- inline: 사례/근거를 괄호 한 줄로 축약하여 인라인
- popup: 상세 내용을 팝업으로 분리하고 링크만 남김
- trim: 텍스트 분량을 줄임 (max_chars 지정)
- restructure: 컨테이너 구조 자체를 변경 (배경 전체폭 등)
"""
async def call_kei_fit_escalation(
fit_report: str,
topics: list[dict],
content_summary: str,
) -> dict[str, Any] | None:
"""Phase V: 적합성 검증 실패 시 Kei에게 판단 요청.
Kei API만 사용. Anthropic 직접 호출 절대 금지.
"""
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
topics_desc = json.dumps(
[
{
"id": t.get("id"),
"title": t.get("title", ""),
"purpose": t.get("purpose", ""),
"source_data": t.get("source_data", "")[:200],
}
for t in topics
],
ensure_ascii=False,
indent=2,
)
prompt = (
KEI_FIT_ESCALATION_PROMPT + "\n\n"
f"## 적합성 검증 결과\n{fit_report}\n\n"
f"## 꼭지 목록\n{topics_desc}\n\n"
f"## 원본 콘텐츠 요약\n{content_summary[:1500]}"
)
try:
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream(
"POST",
f"{kei_url}/api/direct",
json={
"message": prompt,
},
timeout=None,
) as response:
if response.status_code != 200:
logger.warning(f"Kei API (fit) HTTP {response.status_code}")
return None
full_text = await stream_sse_tokens(response)
if full_text:
result = _parse_json(full_text)
if result and "decisions" in result:
logger.info(
f"[V-4] Kei 적합성 판단: "
+ ", ".join(
f"{d['role']}{d['action']}"
for d in result["decisions"]
)
)
return result
logger.warning("[V-4] Kei 적합성 판단 JSON 파싱 실패")
return None
logger.warning("Kei API (fit) 텍스트 추출 실패")
return None
except Exception as e:
logger.warning(f"Kei API (fit) 호출 실패: {e}")
return None