EVMS 완전 자동화: - 공기 지연 AI 예측 (SPI 기반 준공일 예측) - 기성청구 가능 금액 자동 산출 - 매일 자정 EVMS 스냅샷 자동 생성 (APScheduler) - 매일 07:00 GONGSA 아침 브리핑 자동 생성 준공도서 패키지: - 준공 요약 + 품질시험 목록 + 검측 이력 + 인허가 현황 → ZIP 번들 - 준공 준비 체크리스트 API - 4종 HTML 템플릿 (WeasyPrint PDF 출력) Vision AI Level 3: - 설계 도면 vs 현장 사진 비교 보조 판독 (Claude Vision) - 철근 배근, 거푸집 치수 1차 분석 설계도서 파싱: - PDF 이미지/텍스트에서 공종·수량·규격 자동 추출 - Pandoc HWP 출력 지원 발주처 전용 포털: - 토큰 기반 읽기 전용 API - 공사 현황 대시보드, 공정률 추이 차트 에이전트 협업 고도화: - 협업 시나리오 (concrete_pour, excavation, weekly_report) - GONGSA→PUMJIL→ANJEON 순차 처리 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
219 lines
7.0 KiB
Python
219 lines
7.0 KiB
Python
"""
|
|
Vision AI Level 1 — 현장 사진 분류 서비스
|
|
Claude Vision API를 사용하여:
|
|
- 공종 자동 분류
|
|
- 날짜/위치 태깅 (EXIF 또는 수동)
|
|
- 이상 후보 감지 (안전장비 미착용 등)
|
|
- 작업일보 자동 첨부용 캡션 생성
|
|
"""
|
|
import base64
|
|
from anthropic import AsyncAnthropic
|
|
from app.config import settings
|
|
|
|
_client = AsyncAnthropic(api_key=settings.ANTHROPIC_API_KEY)
|
|
|
|
WORK_TYPE_LIST = [
|
|
"콘크리트 타설", "철근 배근", "거푸집 설치/해체", "터파기/굴착",
|
|
"흙막이 공사", "다짐", "관 매설", "아스팔트 포장",
|
|
"고소 작업", "크레인 작업", "안전 시설물", "현장 전경", "기타",
|
|
]
|
|
|
|
_SYSTEM = """당신은 건설 현장 사진을 분석하는 AI입니다.
|
|
사진을 보고 다음을 정확히 분석하세요:
|
|
1. 공종 분류 (단 하나 선택)
|
|
2. 진행 상태 (시작 전 / 진행 중 / 완료)
|
|
3. 안전장비 착용 여부 (안전모, 안전조끼 식별 가능한 경우)
|
|
4. 특이사항 (이상 징후, 주의 필요 사항)
|
|
5. 작업일보용 간략 설명 (1-2문장, 한국어)
|
|
|
|
JSON 형식으로만 응답하세요."""
|
|
|
|
_USER_TEMPLATE = """이 건설 현장 사진을 분석해주세요.
|
|
|
|
공종 목록: {work_types}
|
|
|
|
다음 JSON 형식으로만 응답하세요:
|
|
{{
|
|
"work_type": "콘크리트 타설",
|
|
"status": "진행 중",
|
|
"safety_ok": true,
|
|
"safety_issues": [],
|
|
"anomalies": [],
|
|
"caption": "3공구 기초 콘크리트 타설 작업 진행 중",
|
|
"confidence": 0.85
|
|
}}"""
|
|
|
|
|
|
async def classify_photo(
|
|
image_data: bytes,
|
|
media_type: str = "image/jpeg",
|
|
location_hint: str | None = None,
|
|
) -> dict:
|
|
"""
|
|
현장 사진 분류
|
|
image_data: 이미지 바이너리
|
|
media_type: image/jpeg, image/png, image/webp
|
|
location_hint: 위치 힌트 (예: "3공구", "A구역")
|
|
|
|
반환:
|
|
{
|
|
"work_type": str,
|
|
"status": str,
|
|
"safety_ok": bool,
|
|
"safety_issues": list[str],
|
|
"anomalies": list[str],
|
|
"caption": str,
|
|
"confidence": float,
|
|
"location_hint": str | None,
|
|
}
|
|
"""
|
|
image_b64 = base64.standard_b64encode(image_data).decode()
|
|
|
|
user_content = [
|
|
{
|
|
"type": "image",
|
|
"source": {
|
|
"type": "base64",
|
|
"media_type": media_type,
|
|
"data": image_b64,
|
|
},
|
|
},
|
|
{
|
|
"type": "text",
|
|
"text": _USER_TEMPLATE.format(work_types=", ".join(WORK_TYPE_LIST))
|
|
+ (f"\n\n위치 정보: {location_hint}" if location_hint else ""),
|
|
},
|
|
]
|
|
|
|
response = await _client.messages.create(
|
|
model=settings.CLAUDE_MODEL,
|
|
max_tokens=512,
|
|
system=_SYSTEM,
|
|
messages=[{"role": "user", "content": user_content}],
|
|
)
|
|
|
|
raw = response.content[0].text.strip()
|
|
|
|
# JSON 파싱
|
|
import json, re
|
|
json_match = re.search(r"\{.*\}", raw, re.DOTALL)
|
|
if json_match:
|
|
result = json.loads(json_match.group())
|
|
else:
|
|
result = {
|
|
"work_type": "기타",
|
|
"status": "확인 필요",
|
|
"safety_ok": None,
|
|
"safety_issues": [],
|
|
"anomalies": ["AI 분석 실패 — 수동 확인 필요"],
|
|
"caption": raw[:100],
|
|
"confidence": 0.0,
|
|
}
|
|
|
|
result["location_hint"] = location_hint
|
|
return result
|
|
|
|
|
|
async def compare_with_drawing(
|
|
field_photo: bytes,
|
|
drawing_image: bytes,
|
|
comparison_type: str = "rebar",
|
|
field_media_type: str = "image/jpeg",
|
|
drawing_media_type: str = "image/jpeg",
|
|
) -> dict:
|
|
"""
|
|
Vision AI Level 3 — 설계 도면 vs 현장 사진 비교 (고난도 보조 판독)
|
|
comparison_type: rebar(철근배근), formwork(거푸집), general(일반)
|
|
최종 합격/불합격 판정은 현장 책임자가 합니다.
|
|
"""
|
|
field_b64 = base64.standard_b64encode(field_photo).decode()
|
|
drawing_b64 = base64.standard_b64encode(drawing_image).decode()
|
|
|
|
type_prompts = {
|
|
"rebar": "철근 배근 간격·직경·이음 방법을 도면과 비교해주세요.",
|
|
"formwork": "거푸집 치수·형태·지지 방법을 도면과 비교해주세요.",
|
|
"general": "현장 시공 상태를 도면과 전반적으로 비교해주세요.",
|
|
}
|
|
specific = type_prompts.get(comparison_type, type_prompts["general"])
|
|
|
|
prompt = f"""왼쪽은 설계 도면, 오른쪽은 현장 사진입니다.
|
|
{specific}
|
|
|
|
다음 JSON 형식으로만 응답하세요:
|
|
{{
|
|
"comparison_type": "{comparison_type}",
|
|
"conformances": ["도면과 일치하는 항목"],
|
|
"discrepancies": ["불일치 또는 확인 필요 항목"],
|
|
"risk_level": "저/중/고",
|
|
"recommendation": "현장 책임자에게 전달할 권고사항",
|
|
"confidence": 0.7,
|
|
"disclaimer": "이 결과는 AI 1차 보조 판독이며 최종 판정은 현장 책임자가 합니다."
|
|
}}"""
|
|
|
|
response = await _client.messages.create(
|
|
model=settings.CLAUDE_MODEL,
|
|
max_tokens=1024,
|
|
messages=[{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "image", "source": {"type": "base64", "media_type": drawing_media_type, "data": drawing_b64}},
|
|
{"type": "image", "source": {"type": "base64", "media_type": field_media_type, "data": field_b64}},
|
|
{"type": "text", "text": prompt},
|
|
],
|
|
}],
|
|
)
|
|
|
|
raw = response.content[0].text.strip()
|
|
import json, re
|
|
json_match = re.search(r"\{.*\}", raw, re.DOTALL)
|
|
if json_match:
|
|
return json.loads(json_match.group())
|
|
return {"error": "분석 실패", "raw": raw[:200]}
|
|
|
|
|
|
async def analyze_safety(
|
|
image_data: bytes,
|
|
media_type: str = "image/jpeg",
|
|
) -> dict:
|
|
"""
|
|
Vision AI Level 2 — 안전장비 착용 감지 (안전모/안전조끼)
|
|
PPE(Personal Protective Equipment) 착용 여부 집중 분석
|
|
"""
|
|
image_b64 = base64.standard_b64encode(image_data).decode()
|
|
|
|
safety_prompt = """이 건설 현장 사진에서 안전장비 착용 여부를 분석하세요.
|
|
|
|
다음 JSON 형식으로만 응답하세요:
|
|
{
|
|
"worker_count": 3,
|
|
"helmet_worn": [true, true, false],
|
|
"vest_worn": [true, false, true],
|
|
"violations": ["3번 작업자: 안전모 미착용"],
|
|
"risk_level": "중",
|
|
"recommendation": "안전모 미착용 작업자 즉시 착용 조치 필요"
|
|
}
|
|
|
|
risk_level: 저(모두 착용) / 중(일부 미착용) / 고(다수 미착용 또는 고소 작업)"""
|
|
|
|
response = await _client.messages.create(
|
|
model=settings.CLAUDE_MODEL,
|
|
max_tokens=512,
|
|
messages=[{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "image",
|
|
"source": {"type": "base64", "media_type": media_type, "data": image_b64},
|
|
},
|
|
{"type": "text", "text": safety_prompt},
|
|
],
|
|
}],
|
|
)
|
|
|
|
raw = response.content[0].text.strip()
|
|
import json, re
|
|
json_match = re.search(r"\{.*\}", raw, re.DOTALL)
|
|
if json_match:
|
|
return json.loads(json_match.group())
|
|
return {"error": "분석 실패", "raw": raw[:200]}
|