From 14d02800fa6aaea8e7916bbd3620f16d0a94191c Mon Sep 17 00:00:00 2001 From: KimDongAn Date: Tue, 29 Sep 2026 15:12:39 +0900 Subject: [PATCH] =?UTF-8?q?fix(agents):=20=ED=98=BC=EC=9E=AC=20=EA=B7=BC?= =?UTF-8?q?=EA=B1=B0=20=EA=B0=80=EB=8F=85=EC=84=B1=C2=B7=EB=B0=B0=EC=A0=95?= =?UTF-8?q?=EB=B6=88=EA=B0=80=20=ED=98=BC=EC=9E=AC=20=EA=B2=B0=EA=B3=BC=20?= =?UTF-8?q?=EC=A0=84=EB=8B=AC,=20=EC=9E=85=ED=95=AD=20=EA=B1=B4=20?= =?UTF-8?q?=ED=8C=90=EC=A0=95=20=EB=B3=B4=EC=A0=95=20(9/29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 오케스트레이터: 혼재 배정불가여도 safety_assessment 를 싣는다 — 화면이 '혼재 심사 생략'을 띄우고 근거가 선석 검증 메시지에 한 덩어리로 몰렸다 - 안전 근거(verdict_basis): 이웃 화물당 한 줄로 묶는다(선석 '4부두 1·2선석', 기준 ' / ') - 입항 선박 판정: 입항 건별 최신 판정만 붙인다(지난 입항 건 판정이 새 입항 건에 뜨던 문제) - 마이그레이션 0035: vessel_current_call 이 PORT-MIS 입항 건이 더 새로우면 그것을 따른다 - 기상: 외해 파고는 적용 부두에만(부두 기상 패널·게이트·예보 포함), 예보 공백 구분 - 챗봇 신뢰도, 판정 잡 LLM 호출 축소, 종합 모델 gpt-4.1-mini(.env.example) - 테스트 추가: 판정 가독성·입항 건 키·관제 검토 Co-Authored-By: Claude Opus 5.5 --- .env.example | 3 +- .../sql/0035_current_call_portmis_newer.sql | 76 +++ .../0035_current_call_portmis_newer_down.sql | 58 +++ .../0035_current_call_portmis_newer.py | 39 ++ app/agents/chatbot/graph_queries.py | 13 +- app/agents/chatbot/prompt.py | 13 +- app/agents/chatbot/service.py | 26 +- app/agents/orchestrator/prompt.py | 180 ++----- app/agents/orchestrator/schemas.py | 56 ++- app/agents/orchestrator/service.py | 188 ++++--- app/agents/safety/berth_alerts.py | 48 +- app/agents/safety/msds_context.py | 19 + app/agents/safety/prompt.py | 107 ++-- app/agents/safety/safety_index.py | 24 +- app/agents/safety/schemas.py | 61 ++- app/agents/safety/service.py | 470 ++++++++++++++++-- app/agents/tools.py | 47 +- app/agents/weather/schemas.py | 11 + app/agents/weather/service.py | 34 +- app/api/v1/approvals.py | 22 +- app/api/v1/arrivals.py | 35 +- app/api/v1/orchestrator.py | 30 +- app/gate/bridge.py | 9 +- app/jobs/arrival_watcher.py | 15 +- app/main.py | 2 +- app/services/assessment.py | 68 ++- tests/test_agent_fixes_20260926.py | 3 +- tests/test_controller_review_20260929.py | 67 +++ tests/test_llm_skip_20260926.py | 4 +- tests/test_needs_check_20260927.py | 3 +- tests/test_port_call_key_20260929.py | 37 ++ tests/test_readable_output_20260929.py | 244 +++++++++ tests/test_safety_prompt_20260927.py | 90 +++- tests/test_supervisor_20260927.py | 123 ++++- 34 files changed, 1820 insertions(+), 405 deletions(-) create mode 100644 alembic/sql/0035_current_call_portmis_newer.sql create mode 100644 alembic/sql/0035_current_call_portmis_newer_down.sql create mode 100644 alembic/versions/0035_current_call_portmis_newer.py create mode 100644 tests/test_controller_review_20260929.py create mode 100644 tests/test_port_call_key_20260929.py create mode 100644 tests/test_readable_output_20260929.py diff --git a/.env.example b/.env.example index 863587a..add7425 100644 --- a/.env.example +++ b/.env.example @@ -18,7 +18,8 @@ ANTHROPIC_API_KEY= GEMINI_API_KEY= # LLM_PROVIDER: gemini | openai LLM_PROVIDER=openai -LLM_MODEL=gpt-4.1-nano +# [2026-09-29] nano → mini: 종합 문장이 입력 라벨("[등급 이유]")을 옮겨 적고(24/64) 확인할 항목을 빠뜨렸다(18/62). mini 는 0건, 속도는 판정 1건 +1.5초. +LLM_MODEL=gpt-4.1-mini # 챗봇만 쓰는 모델(같은 provider). 비우면 LLM_MODEL. 도구를 고르는 에이전트라 nano 로는 불안정하다. CHATBOT_LLM_MODEL=gpt-4.1-mini diff --git a/alembic/sql/0035_current_call_portmis_newer.sql b/alembic/sql/0035_current_call_portmis_newer.sql new file mode 100644 index 0000000..14a36af --- /dev/null +++ b/alembic/sql/0035_current_call_portmis_newer.sql @@ -0,0 +1,76 @@ +-- 0035 — mart.vessel_current_call: AIS 입항횟수보다 PORT-MIS 입항 건이 새로우면 PORT-MIS 를 따른다. +-- +-- 왜: 0030 규칙 (a)는 위치 데이터(upa_vessel_position)의 입항연도·횟수를 무조건 앞세웠다. 그런데 +-- 이 값은 AIS 정적 정보라 며칠씩 갱신되지 않는다. 실측(2026-09-29, 로컬): 다인3호(212803)는 +-- PORT-MIS 새 입항 건 #21 이 오늘 13:00 SK2부두 입항인데 마지막 위치 수신(9/27 23:50)의 #20 이 +-- 남아, 판정 잡이 #20 의 화물 6종(가솔린·러버 솔벤트 등)으로 판정했다 — 실제 #21 화물은 3종. +-- 블루오션·케이티에스 골드·우황도 같은 식으로 한 건 뒤처져 있었다(입항 판정 화면 78척 중 4척). +-- 규칙: 두 값이 다 있으면 (입항연도, 입항횟수)가 큰 쪽. PORT-MIS 는 입항 <= now()+12h 건만 보므로 +-- 먼 미래 입항 예정 건으로 넘어가지 않는다. 한쪽만 있으면 종전과 같다. +-- 되돌리기: 0035_current_call_portmis_newer_down.sql (0030 정의). + +CREATE OR REPLACE VIEW mart.vessel_current_call AS +WITH pos AS ( + SELECT DISTINCT ON (upper(btrim(callsgn))) + upper(btrim(callsgn)) AS callsgn, + ptent_yr::int AS entry_year, + voyage_no::int AS entry_count + FROM upa_vessel_position + WHERE nullif(btrim(callsgn), '') IS NOT NULL + ORDER BY upper(btrim(callsgn)), received_at_utc DESC NULLS LAST +), +vessels AS ( + SELECT callsgn FROM pos + UNION + SELECT DISTINCT upper(btrim(callsgn)) FROM portmis_vessel + WHERE nullif(btrim(callsgn), '') IS NOT NULL +), +pm_latest AS ( + SELECT v.callsgn, x.entry_year, x.entry_count, x.arrival_at_utc + FROM vessels v + JOIN LATERAL ( + SELECT p.entry_year, p.entry_count, p.arrival_at_utc + FROM portmis_vessel p + WHERE upper(btrim(p.callsgn)) = v.callsgn + AND p.arrival_at_utc <= now() + interval '12 hours' + AND p.entry_year IS NOT NULL AND p.entry_count IS NOT NULL + ORDER BY p.arrival_at_utc DESC + LIMIT 1 + ) x ON true +), +vts_in AS ( + SELECT upper(btrim(callsgn)) AS callsgn, max(arrival_at_utc) AS last_in + FROM upa_port_call + WHERE io_vts_name = '입항' + GROUP BY 1 +), +resolved AS ( + SELECT v.callsgn, + CASE WHEN use_pos THEN pos.entry_year ELSE pl.entry_year END AS entry_year, + CASE WHEN use_pos THEN pos.entry_count ELSE pl.entry_count END AS entry_count, + CASE WHEN use_pos THEN 'AIS 입항횟수' + WHEN pos.entry_count IS NOT NULL THEN 'PORT-MIS 최근 입항(AIS 입항횟수보다 새 건)' + ELSE 'PORT-MIS 최근 입항' END AS basis, + pl.arrival_at_utc AS portmis_arrival_at_utc + FROM vessels v + LEFT JOIN pos ON pos.callsgn = v.callsgn AND pos.entry_count IS NOT NULL AND pos.entry_year IS NOT NULL + LEFT JOIN pm_latest pl ON pl.callsgn = v.callsgn + LEFT JOIN vts_in vi ON vi.callsgn = v.callsgn + -- [0035] AIS 입항횟수를 쓰되, PORT-MIS 최근 입항 건(입항 <= now()+12h)이 그보다 새 건이면 + -- PORT-MIS 를 따른다. AIS 정적 정보(입항연도·횟수)는 며칠씩 갱신되지 않는다. + CROSS JOIN LATERAL ( + SELECT pos.entry_count IS NOT NULL + AND NOT coalesce((pl.entry_year, pl.entry_count) > (pos.entry_year, pos.entry_count), false) + AS use_pos + ) u + WHERE pos.entry_count IS NOT NULL + OR (pl.entry_count IS NOT NULL + AND NOT coalesce(vi.last_in > pl.arrival_at_utc + interval '48 hours', false)) +) +SELECT callsgn, entry_year, entry_count, + callsgn || '_' || entry_year::text || '_' || lpad(entry_count::text, 3, '0') AS port_call_key, + basis, portmis_arrival_at_utc +FROM resolved; + +COMMENT ON VIEW mart.vessel_current_call IS + '배(콜사인)마다 지금의 입항 건 1개. 위치 데이터의 입항횟수 > PORT-MIS 최근 입항(48h VTS 안전장치). 단 PORT-MIS 최근 입항 건이 AIS 입항횟수보다 새 건이면 PORT-MIS. 연결 못 한 배는 행 없음 = 화물 없음. 0030·0035'; diff --git a/alembic/sql/0035_current_call_portmis_newer_down.sql b/alembic/sql/0035_current_call_portmis_newer_down.sql new file mode 100644 index 0000000..971ab9a --- /dev/null +++ b/alembic/sql/0035_current_call_portmis_newer_down.sql @@ -0,0 +1,58 @@ +-- 0035 되돌리기 — mart.vessel_current_call 을 0030 정의(AIS 입항횟수 우선)로 되돌린다. + +CREATE OR REPLACE VIEW mart.vessel_current_call AS +WITH pos AS ( + SELECT DISTINCT ON (upper(btrim(callsgn))) + upper(btrim(callsgn)) AS callsgn, + ptent_yr::int AS entry_year, + voyage_no::int AS entry_count + FROM upa_vessel_position + WHERE nullif(btrim(callsgn), '') IS NOT NULL + ORDER BY upper(btrim(callsgn)), received_at_utc DESC NULLS LAST +), +vessels AS ( + SELECT callsgn FROM pos + UNION + SELECT DISTINCT upper(btrim(callsgn)) FROM portmis_vessel + WHERE nullif(btrim(callsgn), '') IS NOT NULL +), +pm_latest AS ( + SELECT v.callsgn, x.entry_year, x.entry_count, x.arrival_at_utc + FROM vessels v + JOIN LATERAL ( + SELECT p.entry_year, p.entry_count, p.arrival_at_utc + FROM portmis_vessel p + WHERE upper(btrim(p.callsgn)) = v.callsgn + AND p.arrival_at_utc <= now() + interval '12 hours' + AND p.entry_year IS NOT NULL AND p.entry_count IS NOT NULL + ORDER BY p.arrival_at_utc DESC + LIMIT 1 + ) x ON true +), +vts_in AS ( + SELECT upper(btrim(callsgn)) AS callsgn, max(arrival_at_utc) AS last_in + FROM upa_port_call + WHERE io_vts_name = '입항' + GROUP BY 1 +), +resolved AS ( + SELECT v.callsgn, + CASE WHEN pos.entry_count IS NOT NULL THEN pos.entry_year ELSE pl.entry_year END AS entry_year, + CASE WHEN pos.entry_count IS NOT NULL THEN pos.entry_count ELSE pl.entry_count END AS entry_count, + CASE WHEN pos.entry_count IS NOT NULL THEN 'AIS 입항횟수' ELSE 'PORT-MIS 최근 입항' END AS basis, + pl.arrival_at_utc AS portmis_arrival_at_utc + FROM vessels v + LEFT JOIN pos ON pos.callsgn = v.callsgn AND pos.entry_count IS NOT NULL AND pos.entry_year IS NOT NULL + LEFT JOIN pm_latest pl ON pl.callsgn = v.callsgn + LEFT JOIN vts_in vi ON vi.callsgn = v.callsgn + WHERE pos.entry_count IS NOT NULL + OR (pl.entry_count IS NOT NULL + AND NOT coalesce(vi.last_in > pl.arrival_at_utc + interval '48 hours', false)) +) +SELECT callsgn, entry_year, entry_count, + callsgn || '_' || entry_year::text || '_' || lpad(entry_count::text, 3, '0') AS port_call_key, + basis, portmis_arrival_at_utc +FROM resolved; + +COMMENT ON VIEW mart.vessel_current_call IS + '배(콜사인)마다 지금의 입항 건 1개. 위치 데이터의 입항횟수 > PORT-MIS 최근 입항(48h VTS 안전장치). 연결 못 한 배는 행 없음 = 화물 없음. 0030'; diff --git a/alembic/versions/0035_current_call_portmis_newer.py b/alembic/versions/0035_current_call_portmis_newer.py new file mode 100644 index 0000000..4b00b29 --- /dev/null +++ b/alembic/versions/0035_current_call_portmis_newer.py @@ -0,0 +1,39 @@ +"""current call — AIS 입항횟수보다 PORT-MIS 입항 건이 새로우면 PORT-MIS 를 따른다 + +Revision ID: 0035 +Revises: 0034 +Create Date: 2026-09-29 + +mart.vessel_current_call 의 입항 건 규칙 보정. AIS 정적 정보(입항연도·횟수)가 며칠씩 갱신되지 +않아, 새로 입항한 배를 지난 입항 건의 화물로 판정하고 있었다(실측 다인3호 #20 ↔ #21). +근거와 실측은 ../sql/0035_current_call_portmis_newer.sql 머리 주석. + +되돌리기: 0030 의 뷰 정의를 다시 만든다. 뷰뿐이라 잃는 데이터가 없다. +""" +from pathlib import Path +from typing import Sequence, Union + +from sqlalchemy.util import await_only + +from alembic import op + +revision: str = "0035" +down_revision: Union[str, None] = "0034" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_SQL_DIR = Path(__file__).resolve().parent.parent / "sql" + + +def _exec_sql_file(path: Path) -> None: + # 0028·0029 와 같은 이유로 드라이버 연결에서 파일 전체를 한 번에 실행한다. + raw = op.get_bind().connection.driver_connection + await_only(raw.execute(path.read_text(encoding="utf-8"))) + + +def upgrade() -> None: + _exec_sql_file(_SQL_DIR / "0035_current_call_portmis_newer.sql") + + +def downgrade() -> None: + _exec_sql_file(_SQL_DIR / "0035_current_call_portmis_newer_down.sql") diff --git a/app/agents/chatbot/graph_queries.py b/app/agents/chatbot/graph_queries.py index d448bb2..eb3b0bc 100644 --- a/app/agents/chatbot/graph_queries.py +++ b/app/agents/chatbot/graph_queries.py @@ -158,8 +158,10 @@ async def fetch_bulk_compatibility_groups(driver: AsyncDriver, chem_id: str) -> ([{"category": str, "chemicals": [...]}])이라 service.py에서 그대로 IncompatibleCategoryGroup 리스트에 이어붙일 수 있다. - category 라벨에 "(참고축)"을 명시해 MSDS/IMDG 근거와 구분한다 — 근거의 + category 라벨에 출처(46 CFR 150)를 명시해 MSDS 근거와 구분한다 — 근거의 성격이 다르다는 것을 프롬프트·화면 양쪽에서 알 수 있어야 한다. + [2026-09-29] 예전 라벨 "(참고축)"은 뺐다. 이 축은 안전 에이전트 등급의 근거다 + (compute_bulk_compatibility_floor · verdict_basis) — '참고'라 적으면 판정과 어긋난다. """ group_rows = await _read(driver, _CYPHER_BULK_GROUP_INCOMPATIBLE, chem_id=chem_id) exception_rows = await _read(driver, _CYPHER_BULK_BLOCKED_EXCEPTION, chem_id=chem_id) @@ -167,7 +169,7 @@ async def fetch_bulk_compatibility_groups(driver: AsyncDriver, chem_id: str) -> groups: list[dict] = [ { "category": ( - f"벌크호환성그룹(참고축) {row['my_group_name']}(그룹{row['my_group']}) " + f"46 CFR 150 호환성 그룹 {row['my_group_name']}(그룹{row['my_group']}) " f"↔ {row['other_group_name']}(그룹{row['other_group']})" ), "chemicals": row["chemicals"], @@ -176,7 +178,7 @@ async def fetch_bulk_compatibility_groups(driver: AsyncDriver, chem_id: str) -> ] if exception_rows: groups.append({ - "category": "벌크호환성그룹(참고축) 개별 예외 규정 — 일반 그룹 규칙과 무관하게 강제 격리", + "category": "46 CFR 150 호환성 그룹 개별 예외 규정 — 일반 그룹 규칙과 무관하게 강제 격리", "chemicals": [dict(row) for row in exception_rows], }) return groups @@ -190,8 +192,9 @@ async def fetch_imdg_segregation_groups(driver: AsyncDriver, chem_id: str) -> li return [ { "category": ( - f"IMDG 공인 격리표 Class {row['my_class']}↔{row['other_class']} " - f"(격리코드 {row['segregation_code']})" + # [2026-09-29] 참고임을 이름에 박는다 — 한 선박 안 적부 기준이라 부두 간 판정 근거가 아니다. + f"[참고] IMDG 격리표 Class {row['my_class']}↔{row['other_class']} " + f"(격리코드 {row['segregation_code']}, 한 선박 안 적부 기준 — 부두 간 판정에는 쓰지 않음)" ), "chemicals": row["chemicals"], } diff --git a/app/agents/chatbot/prompt.py b/app/agents/chatbot/prompt.py index 1f42689..dbaaeb5 100644 --- a/app/agents/chatbot/prompt.py +++ b/app/agents/chatbot/prompt.py @@ -110,6 +110,8 @@ def build_followup_prompt(question: str, evidence_text: str) -> str: 쓰세요. 임의로 등급을 올리거나 내리지 마세요. 그 판정은 규칙엔진 하한(MSDS 혼재금지·벌크 호환성그룹·포장기준·판정가능성)으로 보정된 값입니다. 근거로는 충돌이 적힌 축을 인용하세요 — 한 축이 "충돌 없음"이어도 다른 축이 등급을 정했을 수 있습니다. + "[참고"로 시작하는 항목(IMDG 격리표 등)은 등급의 근거로 인용하지 마세요. + 근거에 적힌 화물쌍만 말하고, "울산항에서 ○○의 혼재금지 대상은 A와 B" 같은 일반화는 하지 마세요. 5. 수치(인화점, 끓는점 등)는 근거에 적힌 값과 단위를 그대로 인용하세요. 환산·반올림 하지 마세요. 6. [근거 · 항만 운영 현황]이 있으면 배 이름·부두·화물·판정 등급·시각을 적힌 그대로 @@ -232,27 +234,30 @@ def _format_assessment(result: SafetyAssessmentResult) -> str: ) else: lines.append(" - [MSDS 텍스트 기반 혼재금지] 충돌 없음") + # [2026-09-29] IMDG 는 한 선박 안의 적부 기준이라 부두 간 판정의 근거가 아니다(안전 에이전트 + # verdict_basis 에 없다). '공인 격리표'로만 적어 넘기자 답변이 "IMDG 기준으로도 충돌"을 근거처럼 썼다. + imdg_label = "[참고 · IMDG 격리표 — 한 선박 안 적부 기준이라 부두 간 판정에는 쓰지 않음]" if result.imdg_conflicts: for c in result.imdg_conflicts: lines.append( - f" - [IMDG 공인 격리표] {c.adjacent_name}({c.adjacent_chem_id}) " + f" - {imdg_label} {c.adjacent_name}({c.adjacent_chem_id}) " f"Class {c.adjacent_imdg_class} vs 대상 Class {c.target_imdg_class}, " f"격리코드 {c.segregation_code}" ) else: - lines.append(" - [IMDG 공인 격리표] 충돌 없음") + lines.append(f" - {imdg_label} 충돌 없음") # [2026-09-27] 벌크 호환성그룹 축. 판정(rule_engine_floor)은 이 축으로 '배정불가'가 # 되는데 근거 목록에 없어서, 황산+가성소다처럼 등급은 배정불가인데 근거는 모두 # "충돌 없음"인 입력이 모델에 들어갔고 답변이 "판단 불가"로 나왔다. if result.bulk_compatibility_conflicts: for c in result.bulk_compatibility_conflicts: lines.append( - f" - [벌크 호환성그룹(USCG 46 CFR 150 참고)] {c.adjacent_name}({c.adjacent_chem_id}) " + f" - [판정 근거 · 46 CFR 150 호환성 그룹] {c.adjacent_name}({c.adjacent_chem_id}) " f"그룹 {c.adjacent_group}({c.adjacent_group_name}) vs 대상 그룹 " f"{c.target_group}({c.target_group_name}) — {c.reason}" ) else: - lines.append(" - [벌크 호환성그룹(USCG 46 CFR 150 참고)] 충돌 없음") + lines.append(" - [판정 근거 · 46 CFR 150 호환성 그룹] 충돌 없음") if result.unassessed_pairs: lines.append( f" - [판정 근거 부족] {len(result.unassessed_pairs)}쌍은 MSDS 혼재금지 근거가 없어 " diff --git a/app/agents/chatbot/service.py b/app/agents/chatbot/service.py index fbf647d..350ff30 100644 --- a/app/agents/chatbot/service.py +++ b/app/agents/chatbot/service.py @@ -344,8 +344,9 @@ async def _who_is_at(w: str) -> None: return ChatResponse( answer=_enforce_assessment_level(llm_answer.answer, assessment, llm_answer.conclusion_level), intent=Intent.OPERATIONS, - confidence=( - Confidence.LOW if llm_answer.data_insufficient or not found_any else Confidence.HIGH + confidence=_operational_confidence( + found_any=found_any, has_assessment=assessment is not None, + data_insufficient=llm_answer.data_insufficient, ), chemicals_resolved=resolved, safety_actions=llm_answer.safety_actions, @@ -358,6 +359,20 @@ async def _who_is_at(w: str) -> None: ) +def _operational_confidence(*, found_any: bool, has_assessment: bool, data_insufficient: bool) -> Confidence: + """운영 질문의 신뢰도 (2026-09-29, _compute_confidence 와 같은 원칙). + + 규칙 엔진 판정이 있으면 결론의 근거는 확정돼 있다 — 가상의 배("가솔린 실은 배 옆에 질산")처럼 현황에서 + 배를 못 찾았어도 '낮음'이 아니다. 판정 칸은 '규정 기준으로 확정'인데 바로 아래가 '신뢰도 낮음(근거 부족)' + 이라 서로 반대로 읽혔다(실측). 모델이 자료 부족이라 하면 '보통'까지만 내린다. + """ + if not (found_any or has_assessment): + return Confidence.LOW + if data_insufficient: + return Confidence.MEDIUM if has_assessment else Confidence.LOW + return Confidence.HIGH + + async def _operational_segregation( db: AsyncSession, neo4j_driver: AsyncDriver, @@ -762,8 +777,13 @@ def _compute_confidence( ) -> Confidence: """근거의 종류로 신뢰도를 정한다. LLM에게 자기 확신도를 묻지 않는다 — 환각한 답변일수록 스스로 high를 주는 경향이 있어 신호로 쓸 수 없다.""" - if data_insufficient or not (chunks or assessment or groups): + if not (chunks or assessment or groups): return Confidence.LOW + # [2026-09-29] 모델이 '자료 부족'이라 해도 규칙 엔진 판정(assessment·혼재금지 그룹)이 있으면 결론의 + # 근거는 확정돼 있다 — 판정 칸은 '규칙으로 확정'인데 바로 아래가 '신뢰도 낮음(근거 부족)'이라 서로 + # 반대로 읽혔다(실측: 가솔린↔질산). 서술 쪽 자료가 부족하다는 뜻으로 '보통'까지만 내린다. + if data_insufficient: + return Confidence.MEDIUM if (assessment is not None or groups) else Confidence.LOW # 물질 해석이 흔들리면 그 위에 쌓인 모든 근거가 흔들린다. weak_match = any( diff --git a/app/agents/orchestrator/prompt.py b/app/agents/orchestrator/prompt.py index cccb0b4..80b2fb1 100644 --- a/app/agents/orchestrator/prompt.py +++ b/app/agents/orchestrator/prompt.py @@ -1,137 +1,63 @@ """오케스트레이터 최종 종합 의견 프롬프트. -의사결정 자체(선석 선택, 배정불가 재탐색, 기상 중단)는 이미 결정적 로직으로 -끝난 뒤에 호출된다 — LLM은 그 결과를 관제사가 읽기 좋은 문장으로 요약할 뿐, -승인/거부 판단을 새로 내리지 않는다. +의사결정(선석 확인, 기상 중단, 혼재 부적합)은 이미 결정적 로직으로 끝난 뒤에 호출된다 — +LLM 은 그 결과를 관제사가 읽기 좋은 문장으로 다시 정리할 뿐, 판단을 새로 내리지 않는다. + +[2026-09-29] 입력을 '확정된 판정 사실'로만 바꿨다. 예전엔 순위·배정 경로·탈락 후보·점유 상태를 +넘기고 "선석 추천 이유를 설명하라"고 시켜, 31건 중 28건에 "최적 배정 확정" 같은 없는 사실이 +나왔고 혼재 '주의'의 원인을 화물 유해성으로 바꿔 썼다. 지금은 등급·등급 이유(코드)·축별 결론· +확인 필요·조건·대체 제안만 넘긴다. 비어 있는 칸은 아예 넘기지 않는다 — 넘기면 "(해당 없음)"을 +문장에 옮겨 적었다(실측). """ -from datetime import timedelta, timezone - -from app.agents.safety.schemas import SafetyAssessmentResult -from app.agents.scheduling.schemas import BerthCandidate, VesselSpec -from app.agents.weather.schemas import WeatherAssessmentResult - -from .schemas import RejectedCandidate - -_KST = timezone(timedelta(hours=9)) +from .schemas import OrchestratorRequest, OrchestratorResult SYSTEM_PROMPT = """\ -당신은 울산항 액체화물 하역 관제를 보조하는 AI입니다. -스케줄링·안전관제·기상분석 세 에이전트가 이미 결정한 결과가 아래에 주어집니다. -당신은 새로운 판단을 내리지 말고, 주어진 결과만 근거로 아래 두 가지를 JSON으로 작성하세요. - -1. summary — 관제사가 바로 읽을 수 있는 1~2문단 한국어 종합 의견. 선석 추천 이유, - 위험등급과 핵심 사유, 기상 상태(및 예보 악화 경고가 있다면 그 내용)를 자연스럽게 - 엮어서 설명하세요. - -2. berth_match_summary — 선석 배정현황 화면에서 "이 선석이 왜 이 배와 맞는가"만 - 보여줄 별도의 짧은 한 문장. [최종 추천 선석]·[선박 정보]·[배정 경로]· - [재탐색 과정에서 탈락한 후보]·[기상 상태]를 전부 활용해서, 수심 대비 흘수가 - 얼마나 여유 있는지뿐 아니라 전용/대체 중 어느 경로로 확정됐는지, 대체라면 - 원래 선석은 왜 안 됐는지, 기상 임계값을 통과했는지까지 근거를 다양하게 - 엮으세요. **수심 얘기 하나로 문장을 끝내지 마세요** — 주어진 항목 중 최소 - 2가지 이상을 언급해야 합니다. **[안전관제 결과]는 이 문장에 절대 쓰지 마세요** - — 화학물질 이름·위험등급·유해성 언급이 하나라도 들어가면 안 됩니다. 그 - 정보는 summary에만 담깁니다. +당신은 울산항 액체화물 하역 관제 화면의 '종합 의견' 문장을 쓰는 보조자입니다. +[판정 사실]은 규칙 엔진이 확정한 내용입니다. 새로 판단하지 말고 관제사가 한눈에 읽도록 정리만 하세요. +1. 1~2문장, 120자 이내 한국어. 존댓말 평서문. +2. 등급 단어('적합'·'주의' 등)나 '최종 등급'으로 시작하지 마세요 — 화면이 등급을 따로 크게 보여줍니다. + 첫 문장은 [등급 이유]를 그대로 풀어 쓰세요. 이유를 바꾸거나 더하지 마세요. +3. [확인 필요]가 주어지면 그 항목을 빠짐없이 관제사가 확인할 일로 한 문장에 담으세요. +4. 주어진 사실과 숫자만 쓰세요. 없는 원인·조치·예측을 만들지 마세요. +5. 선석을 추천·선정하는 말('추천', '최적', '확정', '선정', '배정')을 쓰지 말고 다른 선석으로 옮기라고 권하지 마세요. + 단 [등급 이유]의 '배정된 선석'(PORT-MIS 사전배정 사실)은 그대로 옮겨도 됩니다. + [대체 선석 제안]이 주어졌을 때만 '제안'으로 언급하세요. +6. 화물 자체의 일반 유해성(인화성·독성 등)은 쓰지 마세요 — 화면의 별도 칸에 있습니다. +7. 문제가 없는 축은 하나하나 나열하지 말고 '선석·기상·혼재 모두 기준 안'처럼 묶으세요. + 적합인 축의 수치(흘수 여유·풍속 등)는 다시 적지 마세요 — 화면이 축별로 이미 보여줍니다. + 단 근거가 '비교 대상 없음'인 축은 기준 안으로 묶지 말고 비교할 대상이 없었다고 그대로 쓰세요. +8. 입력의 대괄호 제목([등급 이유] 등)을 문장에 옮겨 적지 마세요. """ -def _format_weather(weather: WeatherAssessmentResult, *, is_global_fallback: bool = False) -> str: - label = "현재 상태(전역 기본값 — 이 후보의 배정 실패 사유가 아닐 수 있음)" if is_global_fallback else "현재 상태" - lines = [f"{label}: {weather.status.value}"] - if weather.forecast_warning: - fw = weather.forecast_warning - if fw.will_deteriorate: - deteriorate_at_kst = ( - fw.earliest_deterioration_at_utc.astimezone(_KST).strftime("%m월 %d일 %H:%M") - if fw.earliest_deterioration_at_utc - else "미상" - ) - lines.append( - f"예보 경고: {deteriorate_at_kst}(KST)부터 {fw.worst_status.value} 수준으로 악화 예상" - ) - else: - lines.append("예보 경고: 하역 완료 예정 시각까지 악화 없음") +# 위 규칙 5의 금지어. 우리는 선석을 배정·추천하지 않고 검증만 한다 — 프롬프트로 막아도 모델이 가끔 +# "최종 추천 선석인 ○○는"처럼 쓴다(9/28 판정 기록 실측). 이 말이 든 문장은 코드가 템플릿으로 바꾼다. +ASSIGNMENT_WORDS = ("추천", "최적", "확정", "선정", "배정") + + +def _axis_level(o) -> str: + # 이웃 화물이 없어 비교를 못 한 혼재 축은 '적합'이 아니라 '비교 대상 없음'으로 넘긴다 — '적합'으로 + # 넘기면 규칙으로 막아도 "혼재 모두 기준 안"으로 묶었다(실측 10건 중 4건). + if o.level == "적합" and any("비교 대상 없음" in e for e in o.evidence): + return "비교 대상 없음" + return o.level + + +def build_user_prompt(result: OrchestratorResult, request: OrchestratorRequest) -> str: + lines = [ + f"[대상] {request.vessel.name_hint or '선박'} / {request.assigned_wharf_name}", + f"[등급] {result.level}", + f"[등급 이유] {result.headline}", + "[축별 결론]", + *(f"- {o.axis}: {_axis_level(o)} — {' · '.join(o.evidence[:3]) or '근거 없음'}" for o in result.opinions), + ] + if result.needs_check: + lines += ["[확인 필요]", *(f"- {m}" for m in result.needs_check)] + if result.conditions: + lines += ["[조건]", *(f"- {c}" for c in result.conditions)] + if result.suggested_alternatives: + lines += ["[대체 선석 제안]", ", ".join( + f"{c.wharf_name}(흘수 여유 {c.draught_margin_m:.1f}m)" for c in result.suggested_alternatives + )] return "\n".join(lines) - - -def _format_safety(safety: SafetyAssessmentResult | None) -> str: - if safety is None: - return "(안전관제 미실시)" - return ( - f"위험등급: {safety.risk_level.value}\n" - f"핵심 유해성: {', '.join(safety.key_hazards) if safety.key_hazards else '없음'}\n" - f"판단 근거: {safety.reasoning}" - ) - - -def _format_rejected(rejected: list[RejectedCandidate]) -> str: - if not rejected: - return "(없음)" - return "\n".join(f" - {r.rank}순위 {r.berth_id}: {r.reason}" for r in rejected) - - -def _format_vessel(vessel: VesselSpec | None) -> str: - if vessel is None: - return "(정보 없음)" - parts = [f"흘수 {vessel.draught_m}m"] - if vessel.dwt_t is not None: - parts.append(f"DWT {vessel.dwt_t:,.0f}t") - if vessel.name_hint: - parts.append(vessel.name_hint) - return " · ".join(parts) - - -def build_user_prompt( - *, - selected_berth: BerthCandidate | None, - safety: SafetyAssessmentResult | None, - weather: WeatherAssessmentResult, - rejected_candidates: list[RejectedCandidate], - vessel: VesselSpec | None = None, - assignment_trace: list[str] | None = None, - is_global_fallback_weather: bool = False, -) -> str: - # berth_match_summary가 "수심 얘기만" 나오던 원인이 여기였다(2026-08-19 지적) — - # 예전엔 이 함수가 draught_margin_m 하나만 문장으로 만들어 넘겼다. LLM은 준 - # 것만 쓸 수 있으므로, 선석 스펙 전체(수심·정원·기상그룹)와 선박 스펙, - # 전용/대체 판단 경로까지 전부 명시적으로 넘긴다. - if selected_berth: - berth_lines = [ - f"{selected_berth.rank}순위 {selected_berth.wharf_name}" - f"({selected_berth.port_name or '항만 미상'})", - f"수심 {selected_berth.depth_m}m · 흘수여유 {selected_berth.draught_margin_m:.1f}m" - f" · 배정 전 상태 {selected_berth.occupancy_status.value}", - ] - if selected_berth.berth_group: - berth_lines.append(f"기상 임계값 그룹: {selected_berth.berth_group}") - if selected_berth.unload_capacity is not None: - berth_lines.append(f"하역능력: {selected_berth.unload_capacity}") - berth_desc = "\n".join(berth_lines) - else: - berth_desc = "(추천 선석 없음)" - - trace_desc = " → ".join(assignment_trace) if assignment_trace else "(단일 후보, 재탐색 없음)" - - return f"""\ -[최종 추천 선석] -{berth_desc} - -[선박 정보] -{_format_vessel(vessel)} - -[배정 경로] -{trace_desc} - -[안전관제 결과] -{_format_safety(safety)} - -[기상 상태] -{_format_weather(weather, is_global_fallback=is_global_fallback_weather)} - -[재탐색 과정에서 탈락한 후보] -{_format_rejected(rejected_candidates)} - -위 정보를 종합해 summary와 berth_match_summary를 JSON으로 응답하세요. -berth_match_summary에는 [안전관제 결과] 내용을 쓰지 마세요. -""" diff --git a/app/agents/orchestrator/schemas.py b/app/agents/orchestrator/schemas.py index 6b7aa8d..3d3dcf1 100644 --- a/app/agents/orchestrator/schemas.py +++ b/app/agents/orchestrator/schemas.py @@ -7,6 +7,7 @@ from datetime import datetime from enum import Enum +from typing import Literal from pydantic import BaseModel, Field, model_validator @@ -27,9 +28,11 @@ class OverallDecision(str, Enum): "승인가능" -> "적합" 우리는 승인하지 않는다. 조건에 맞는지만 말한다. "전후보배정불가" -> "전 후보 부적합" 우리는 배정을 거부하는 주체가 아니다. - '배정'이라는 단어 자체를 지운 것이 아니다 — `NO_ELIGIBLE_BERTH`("적합선석없음")나 - 판정 근거의 "배정된 선석이…"는 **남이 한 배정**을 가리키는 정확한 서술이라 그대로 - 둔다. 문제는 그 배정을 우리가 한다고 읽히는 어휘였다. + '배정'이라는 단어 자체를 지운 것이 아니다 — `NO_ELIGIBLE_BERTH`("적합선석없음")는 + **남이 한 배정**을 가리키는 서술이라 그대로 둔다. 문제는 그 배정을 우리가 한다고 읽히는 + 어휘였다. [2026-09-29] 판정 문구의 "배정된 선석이…"는 걷어냈다 — 판정 대상은 대개 + AIS 로 본 실제 접안 부두이고 PORT-MIS 신고와 다르다(실측 27척 중 26척). 출처대로 + "지금 접안한 선석" / "배정된 선석"이라 쓴다(target_source). 이 값은 DB 에 저장되지 않는다(`assessment_history` 는 `AssessmentLevel` 을 쓴다). 화면이 `decision_label` 로 그대로 표시하므로 값이 곧 관제사가 읽는 문장이다. @@ -70,6 +73,16 @@ class OrchestratorRequest(BaseModel): "신고 선석. [2026-09-27] 필수다: 선석을 새로 고르는 탐색모드를 걷어냈다(우리는 " "배정하지 않는다). 다른 자리가 필요하면 대체 선석 제안(/scheduling/alternatives)을 쓴다.", ) + target_source: Literal["AIS", "PORT-MIS"] | None = Field( + default=None, + description="[2026-09-29] assigned_wharf_name 의 출처. AIS=실제 접안 부두, PORT-MIS=접안 전 신고 선석. " + "판정 문구가 '지금 접안한 선석'/'배정된 선석'으로 갈린다. 비우면 '이 선석'.", + ) + reported_wharf_name: str | None = Field( + default=None, + description="[2026-09-29] PORT-MIS 신고 계류시설. 실제 접안 부두와 다르면 판정 경로에 한 줄 남긴다 " + "(신고 정정이 필요하다는 신호).", + ) @model_validator(mode="after") def _window_must_be_ordered(self) -> "OrchestratorRequest": @@ -89,10 +102,10 @@ class RejectedCandidate(BaseModel): class OrchestratorResult(BaseModel): overall_decision: OverallDecision selected_berth: BerthCandidate | None = Field( - default=None, description="최종 추천 선석. 승인가능이 아니면 None" + default=None, description="검증을 통과한 선석. 승인가능이 아니면 None" ) safety_assessment: SafetyAssessmentResult | None = Field( - default=None, description="선택된 선석에 대한 안전관제 결과. 기상불가/적합선석없음이면 None" + default=None, description="배정된 선석에 대한 안전관제 결과(혼재 배정불가여도 싣는다). 기상불가/적합선석없음이면 None" ) weather_assessment: WeatherAssessmentResult rejected_candidates: list[RejectedCandidate] = Field( @@ -121,12 +134,22 @@ class OrchestratorResult(BaseModel): "회의 §4 '근거 부족을 안전과 구분' — 이 값이 assessment_history.level 에서 " "'판정불가'와 '부적합'을 가른다.", ) - summary: str = Field(description="관제사가 읽을 종합 의견 (1~2문단)") - berth_match_summary: str | None = Field( - default=None, - description="선석배정현황 팝업 전용 — 선석 스펙과 선박 매칭만 다루는 LLM 한 문장 " - "요약(화학물질·안전판정 내용 제외). summary와 같은 LLM 호출에서 함께 받는다" - "(호출 두 번 비용 방지). selected_berth가 없으면 None.", + summary: str = Field(description="관제사가 읽을 종합 의견 (1~2문장)") + # [2026-09-29] berth_match_summary(선석배정현황 팝업용 LLM 한 문장)를 없앴다 — 화면이 더 이상 + # 쓰지 않고(BerthAssignmentMap 은 판정 기록 reasons 를 쓴다) 문장만 매번 만들고 있었다. + level: str = Field( + default="", + description="[2026-09-29] 판정 등급 — 적합 | 주의 | 부적합 | 판정불가. 판정 기록(assessment_history.level)과 " + "같은 함수로 정한다. 화면은 overall_decision 이 아니라 이 값으로 등급·색을 정한다 " + "(예전엔 혼재 '주의'가 초록 '적합'으로, 근거 부족이 빨간 '적합선석없음'으로 보였다).", + ) + headline: str = Field(default="", description="[2026-09-29] 등급의 이유 한 줄(판정 기록 reasons 첫 줄과 같다)") + needs_check: list[str] = Field( + default_factory=list, + description="[2026-09-29] 확인 필요 — 도구 의견의 '못 본 것'을 모은 것('대상 — 이유')", + ) + target_source: Literal["AIS", "PORT-MIS"] | None = Field( + default=None, description="판정한 선석의 출처(요청값 그대로)" ) opinions: list[Opinion] = Field( default_factory=list, @@ -158,8 +181,6 @@ def decision_detail(self) -> dict: /대체 경로)만 다뤄야 두 화면의 책임이 안 섞인다. """ detail: dict = {"trace": self.assignment_trace} - if self.berth_match_summary: - detail["narrative"] = self.berth_match_summary if self.selected_berth: detail["berth"] = { "rank": self.selected_berth.rank, @@ -181,11 +202,6 @@ def decision_detail(self) -> dict: class LLMSummary(BaseModel): - """Gemini response_schema로 강제할 최종 종합 의견 구조화 출력.""" + """LLM 구조화 출력 — 확정된 판정 사실을 관제사용 문장으로 다시 정리한 것.""" - summary: str = Field(description="관제사가 바로 읽을 수 있는 종합 의견, 1~2문단 한국어") - berth_match_summary: str = Field( - description="선석 스펙(수심·정원·전용/대체 여부)이 이 선박과 왜 맞는지만 다루는 " - "한 문장. 화학물질명·위험등급·유해성 등 안전판정 내용은 절대 넣지 말 것 — " - "선석 배정 근거 화면 전용이라 안전 얘기가 섞이면 안 된다.", - ) + summary: str = Field(description="관제사가 바로 읽을 종합 의견, 1~2문장 한국어") diff --git a/app/agents/orchestrator/service.py b/app/agents/orchestrator/service.py index a1cae9c..de0a69a 100644 --- a/app/agents/orchestrator/service.py +++ b/app/agents/orchestrator/service.py @@ -1,13 +1,14 @@ """오케스트레이터 — 정해진 선석 하나가 지금 이 배·화물에 맞는가(검증모드 전용). 흐름 (감독자 — 27번 설계안): -1. 선석 도구(check_berth): 배정된 선석의 가용수심·흘수 여유·이웃 화물. 맞지 않으면 +1. 선석 도구(check_berth): 판정할 선석의 가용수심·흘수 여유·이웃 화물. 맞지 않으면 종료 — 근거 부족이면 판정불가, 실제 부적합이면 대체 선석 **제안**을 붙인다. 2. 기상 도구(check_weather): 그 선석의 임계값으로 판정. 정상이 아니면 종료 (관측 없음 → 판정불가, 기준 초과 → 기상 사유 + 대체 제안). 3. 혼재 도구(check_segregation): 이웃 화물과의 규칙 하한. 배정불가면 부적합 + 대체 제안. 4. 교차 확인(_cross_check): 체류 중 예보 악화 등 조건을 붙인다. -5. 적합/부적합일 때만 LLM 이 관제사용 문장을 쓴다(등급은 바꾸지 못한다). +5. 마무리(_finish): 판정 기록과 같은 함수로 등급·등급 이유를 정하고, 도구 의견의 '못 본 것'을 + '확인 필요'로 모은 뒤, 판정불가가 아니면 LLM 이 관제사용 문장을 쓴다(등급은 바꾸지 못한다). 도구마다 의견(Opinion: 등급 · 확인한 것 · 못 본 것)을 모아 결과에 싣는다. [2026-09-27] 탐색모드(선석 top-3 를 새로 고르고, 점유면 대체 선석 → 정박지 대기로 @@ -16,20 +17,25 @@ 추천·'적합 선석 없음')을 보여 주고 있었다. 정박지는 위치로 관찰만 한다(/dashboard/anchorages). """ +import logging from datetime import timedelta, timezone from neo4j import AsyncDriver from sqlalchemy.ext.asyncio import AsyncSession from app.agents import tools -from app.agents.safety.schemas import RiskLevel, SafetyAssessmentResult -from app.agents.scheduling.schemas import BerthCandidate, OccupancyStatus, VesselSpec +from app.agents.safety.schemas import RiskLevel +from app.agents.scheduling.schemas import OccupancyStatus from app.agents.weather.schemas import WeatherAssessmentResult, WorkStatus from app.llm.base import LLMClient +from app.models.assessment_history import AssessmentLevel +from app.services.assessment import level_from_decision -from .prompt import SYSTEM_PROMPT, build_user_prompt +from .prompt import ASSIGNMENT_WORDS, SYSTEM_PROMPT, build_user_prompt from .schemas import LLMSummary, OrchestratorRequest, OrchestratorResult, OverallDecision, RejectedCandidate +logger = logging.getLogger(__name__) + _KST = timezone(timedelta(hours=9)) @@ -51,41 +57,81 @@ def _cross_check(weather: WeatherAssessmentResult) -> tuple[list[str], str]: and fw.earliest_deterioration_at_utc is not None ): at = fw.earliest_deterioration_at_utc.astimezone(_KST) + # [2026-09-29] 무엇이 기준을 넘는 예보인지 적는다 — "하역중단 예보"만으로는 바람인지 비인지 몰랐다. + cause = f"({' · '.join(fw.earliest_deterioration_causes)}) " if fw.earliest_deterioration_causes else "" return ( - [f"체류 중 {at:%m/%d %H:%M} 부터 '{fw.worst_status.value}' 예보 — " + [f"체류 중 {at:%m/%d %H:%M} 부터 '{fw.worst_status.value}' 예보 {cause}— " f"그 시각 관측이 기준을 넘으면 기상 게이트가 하역을 멈춥니다"], f"예보:{fw.worst_status.value}", ) return [], "" -async def _llm_summary( - llm_client: LLMClient, - *, - selected_berth: BerthCandidate | None, - safety: SafetyAssessmentResult | None, - weather: WeatherAssessmentResult, - rejected: list[RejectedCandidate], - vessel: VesselSpec | None = None, - assignment_trace: list[str] | None = None, - is_global_fallback_weather: bool = False, -) -> LLMSummary: - user_prompt = build_user_prompt( - selected_berth=selected_berth, - safety=safety, - weather=weather, - rejected_candidates=rejected, - vessel=vessel, - assignment_trace=assignment_trace, - is_global_fallback_weather=is_global_fallback_weather, - ) - return await llm_client.generate_structured( - system_prompt=SYSTEM_PROMPT, user_prompt=user_prompt, schema=LLMSummary - ) +def _place(request: OrchestratorRequest, wharf_name: str) -> str: + """판정 경로 문장의 주어 — 선석이 어디서 왔는지 그대로 적는다(2026-09-29).""" + if request.target_source == "AIS": + return f"지금 접안한 '{wharf_name}'" + if request.target_source == "PORT-MIS": + # 아직 붙지 않은 배다 — '접안'이 아니라 PORT-MIS 가 사전배정한 자리다(2026-09-29). + return f"배정된 '{wharf_name}'" + return f"'{wharf_name}'" + +def _reported_note(request: OrchestratorRequest) -> list[str]: + """실제 접안 부두와 PORT-MIS 신고 계류시설이 다르면 한 줄 — 신고 정정이 필요하다는 신호다. + + 표기만 다른 같은 부두(띄어쓰기·대소문자)는 같다고 본다. 별칭까지는 풀지 않으므로 '다르다'고 + 단정하지 않고 신고값만 적는다. + """ + reported = (request.reported_wharf_name or "").strip() + norm = lambda s: s.replace(" ", "").upper() # noqa: E731 + if request.target_source != "AIS" or not reported or norm(reported) == norm(request.assigned_wharf_name): + return [] + return [f"PORT-MIS 신고 계류시설: '{reported}'"] -def _no_candidate_summary(reason: str) -> str: - return f"{reason} 안전하게 배정을 진행할 수 없어 관제사의 직접 확인이 필요합니다." + +def _template_summary(result: OrchestratorResult) -> str: + """LLM 없이 쓰는 종합 문장 — 판정불가이거나 LLM 을 부르지 않는 회차(판정 잡).""" + head = result.headline.rstrip(". ") + if result.level == AssessmentLevel.UNKNOWN.value: + # 빠진 근거가 '확인 필요'에 이미 적혀 있으면 같은 문장을 되풀이하지 않는다 — 판정불가 건은 + # 등급 이유와 확인 필요가 같은 문장이라 화면에 세 번 떴다(실측 장생포호안). + if result.needs_check: + return "판정에 필요한 근거가 없어 판정하지 못했습니다. 확인 필요 항목의 빠진 근거를 채운 뒤 다시 판정해야 합니다." + return f"{head}. 빠진 근거를 채운 뒤 다시 판정해야 합니다." + if result.needs_check: + return f"{head}. 확인 필요: {', '.join(m.split(' — ')[0] for m in result.needs_check)}." + return f"{head}." + + +async def _finish( + result: OrchestratorResult, request: OrchestratorRequest, llm_client: LLMClient | None, +) -> OrchestratorResult: + """등급·등급 이유·확인 필요를 채우고 종합 문장을 붙인다. + + 등급은 판정 기록과 **같은 함수**(level_from_decision)로 정한다 — 예전엔 화면이 + overall_decision 을 그대로 써서, 기록은 '주의'인데 콘솔은 초록 '적합'으로 보였다. + 판정불가면 LLM 을 부르지 않는다 — 정리할 판정이 없고, 템플릿이 빠진 근거를 그대로 말한다. + """ + result.target_source = request.target_source + level, headline = level_from_decision(result) + result.level = level.value + result.headline = headline + result.needs_check = list(dict.fromkeys(m for o in result.opinions for m in o.missing)) + summary = "" + if llm_client is not None and level is not AssessmentLevel.UNKNOWN: + llm_result = await llm_client.generate_structured( + system_prompt=SYSTEM_PROMPT, + user_prompt=build_user_prompt(result, request), + schema=LLMSummary, + ) + summary = llm_result.summary.strip() + # '배정된 선석'은 PORT-MIS 사전배정 사실이라 금지어 검사에서 뺀다(등급 이유의 주어다). + if any(w in summary.replace("배정된", "") for w in ASSIGNMENT_WORDS): + logger.warning("종합 문장에 배정·추천 어휘 — 템플릿으로 대체: %s", summary) + summary = "" + result.summary = summary or _template_summary(result) + return result async def orchestrate( @@ -93,11 +139,32 @@ async def orchestrate( neo4j_driver: AsyncDriver, llm_client: LLMClient, request: OrchestratorRequest, + *, + summarize: bool = True, +) -> OrchestratorResult: + """summarize=False 면 종합 문장을 템플릿으로 쓴다(안전 서술 LLM 은 그대로 부른다). + + 판정 잡이 쓴다 — 종합 문장은 판정 기록에 저장되지 않아, 잡에서 LLM 으로 쓰면 버려진다. + """ + result = await _judge(db, neo4j_driver, llm_client, request) + return await _finish(result, request, llm_client if summarize else None) + + +async def _judge( + db: AsyncSession, + neo4j_driver: AsyncDriver, + llm_client: LLMClient, + request: OrchestratorRequest, ) -> OrchestratorResult: + """판정 본체 — 종합 문장(summary)은 비워 두고 _finish 가 채운다.""" # 전역 폴백 판정(__GLOBAL_DEFAULT__ 임계값). 선석을 확인하지 못했거나 혼재로 부적합인 # 응답의 weather_assessment 를 채우는 용도다 — 통과/차단은 아래 선석별 임계값으로 한다. + # [2026-09-29] 판정 대상 시설 이름을 넘긴다 — 파고 적용 규칙(wave_applies_to)이 이름으로 정해진다. + # 안 넘기면 외해 파고가 대입돼, 선석 단계에서 끝난 판정의 기상 칸이 "달포부두 기상 판정: 하역중단 + # (파고 1.6m)"으로 보였다(실측). weather_result = await tools.check_weather( - db, window_end=request.window_end, as_of=request.weather_as_of, + db, window_end=request.window_end, wharf_name=request.assigned_wharf_name, + as_of=request.weather_as_of, ) opinions: list[tools.Opinion] = [] @@ -124,7 +191,7 @@ async def _alternatives() -> tools.Alternatives: opinions.append(tools.berth_opinion(berth_check)) candidate = berth_check.candidate if candidate is None: - # 배정된 시설이 **실제로** 안 맞는 경우에만 대체안을 찾는다(회의 §3 조치안). + # 판정할 시설이 **실제로** 안 맞는 경우에만 대체안을 찾는다(회의 §3 조치안). # 근거 부족(표기 미해소·조위 예보 없음)이면 찾지 않는다 — 어디가 # 문제인지도 모르는 채로 다른 자리를 권하는 건 근거 없는 조언이다. suggestion = tools.Alternatives() if berth_check.evidence_missing else await _alternatives() @@ -132,10 +199,10 @@ async def _alternatives() -> tools.Alternatives: overall_decision=OverallDecision.NO_ELIGIBLE_BERTH, weather_assessment=weather_result, evidence_missing=berth_check.evidence_missing, - assignment_trace=[berth_check.reason] if berth_check.reason else [], + assignment_trace=([berth_check.reason] if berth_check.reason else []) + _reported_note(request), suggested_alternatives=suggestion.candidates, suggestion_note=suggestion.note, - summary=_no_candidate_summary(berth_check.reason or "사전배정 선석을 확인할 수 없습니다."), + summary="", opinions=opinions, ) @@ -144,6 +211,7 @@ async def _alternatives() -> tools.Alternatives: # "이 자리가 맞나"다. PORT-MIS 출항 시각이 예정값이라 동시 계류를 과대 계산하고 # (백테스트 7/125), 점유의 근거인 AIS 실측은 대개 검증 대상 자신이다. # 점유 사실은 trace 에 남겨 관제사가 "이 부두에 지금 몇 척이 있다"를 볼 수 있게 한다. + # 종합 문장(LLM) 입력에는 넣지 않는다 — 넣었더니 "다른 시설로 배정 고려"라고 썼다. occ_note = ( f"참고: 이 시설에 다른 선박이 접안 중입니다(AIS 실측). " f"판정 등급에는 반영하지 않습니다" @@ -151,9 +219,10 @@ async def _alternatives() -> tools.Alternatives: else None ) trace = [ - f"배정된 시설 '{candidate.wharf_name}' 확인 " + f"{_place(request, candidate.wharf_name)} 확인 " f"(수심 {candidate.depth_m}m, 흘수 {request.vessel.draught_m}m, " f"여유 {candidate.draught_margin_m:.1f}m)", + *_reported_note(request), *([occ_note] if occ_note else []), ] @@ -169,7 +238,7 @@ async def _alternatives() -> tools.Alternatives: ) opinions.append(tools.weather_opinion(berth_weather)) if berth_weather.status is not WorkStatus.NORMAL: - # [2026-09-26] 선석이 아니라 기상 때문이다 — '배정된 선석이 맞지 않는다'가 아니라 + # [2026-09-26] 선석이 아니라 기상 때문이다 — '선석이 맞지 않는다'가 아니라 # 기상 사유로 판정한다. 관측이 없거나 낡았으면 근거 부족(판정불가)이고 대체안도 # 찾지 않는다. if berth_weather.status is WorkStatus.UNKNOWN: @@ -178,9 +247,7 @@ async def _alternatives() -> tools.Alternatives: weather_assessment=berth_weather, evidence_missing=True, assignment_trace=trace, - summary=_no_candidate_summary( - f"'{candidate.wharf_name}' 기상 관측이 없거나 오래돼 판단할 수 없습니다." - ), + summary="", opinions=opinions, ) suggestion = await _alternatives() @@ -190,10 +257,7 @@ async def _alternatives() -> tools.Alternatives: assignment_trace=trace, suggested_alternatives=suggestion.candidates, suggestion_note=suggestion.note, - summary=( - f"'{candidate.wharf_name}' 기상이 '{berth_weather.status.value}' 기준을 " - f"넘었습니다: {'; '.join(berth_weather.reasons)}" - ), + summary="", opinions=opinions, ) @@ -205,29 +269,20 @@ async def _alternatives() -> tools.Alternatives: cargo=request.cargo, cargos=request.cargos, adjacent_cargos=candidate.adjacent_cargos, + call_sign=request.vessel.call_sign, ) opinions.append(tools.segregation_opinion(safety_result)) if safety_result.risk_level != RiskLevel.BLOCKED: - # ── 4. 교차 확인 → 5. 설명 ───────────────────────────────────────── + # ── 4. 교차 확인 ─────────────────────────────────────────────────── conditions, condition_key = _cross_check(berth_weather) - llm_result = await _llm_summary( - llm_client, - selected_berth=candidate, - safety=safety_result, - weather=berth_weather, - rejected=[], - vessel=request.vessel, - assignment_trace=trace, - ) return OrchestratorResult( overall_decision=OverallDecision.APPROVED, selected_berth=candidate, safety_assessment=safety_result, weather_assessment=berth_weather, assignment_trace=trace, - summary=llm_result.summary, - berth_match_summary=llm_result.berth_match_summary, + summary="", opinions=opinions, conditions=conditions, condition_key=condition_key, @@ -235,26 +290,21 @@ async def _alternatives() -> tools.Alternatives: # 혼재 배정불가 — 이 선석이 이 화물에 맞지 않는다. 대체 선석을 제안한다. rejected = [ - RejectedCandidate(berth_id=candidate.berth_id, rank=candidate.rank, reason=safety_result.reasoning) + RejectedCandidate( + berth_id=candidate.berth_id, rank=candidate.rank, + reason="; ".join(safety_result.verdict_basis) or safety_result.reasoning, + ) ] - llm_result = await _llm_summary( - llm_client, - selected_berth=None, - safety=None, - weather=weather_result, - rejected=rejected, - vessel=request.vessel, - # weather_result 는 전역 폴백값이다 — LLM 에게 이 값이 이 선석 판정이 아님을 알린다. - is_global_fallback_weather=True, - ) suggestion = await _alternatives() return OrchestratorResult( overall_decision=OverallDecision.ALL_CANDIDATES_UNSAFE, + # [2026-09-29] 혼재 심사 결과를 싣는다 — 빠져 있어 화면이 '혼재 심사 생략'을 띄우고, 근거가 + # 선석 검증 메시지(rejected reason)에 한 덩어리로 몰렸다(사용자 지적, BUENA DIOSA). + safety_assessment=safety_result, weather_assessment=weather_result, rejected_candidates=rejected, suggested_alternatives=suggestion.candidates, suggestion_note=suggestion.note, - summary=llm_result.summary, - # berth_match_summary는 안 담는다 — 선택된 선석이 없어 "매칭"을 말할 대상 자체가 없다. + summary="", opinions=opinions, ) diff --git a/app/agents/safety/berth_alerts.py b/app/agents/safety/berth_alerts.py index 72e555b..ee468b2 100644 --- a/app/agents/safety/berth_alerts.py +++ b/app/agents/safety/berth_alerts.py @@ -227,6 +227,9 @@ def _of(rows: list[dict], oid: str) -> list[dict]: } name_of = {c["chem_id"]: (c["cargo_name"] or c["chem_id"]) for c in identified} + carriers = {} + for c in identified: + carriers.setdefault(c["chem_id"], set()).add(c["callsgn"]) for raw in raw_conflicts + raw_imdg + unconfirmed_imdg + raw_bulk: other_id = raw["chem_id"] floor = pair_floor[other_id] @@ -238,14 +241,29 @@ def _of(rows: list[dict], oid: str) -> list[dict]: continue seen.add(pair) + # [2026-09-29] 두 화물을 **한 배만** 싣고 있으면 부두 안의 두 배가 아니라 선내 적재다. + # 안전 에이전트(service._compute_onboard_conflicts)와 같이 '주의 — 격리 적재 확인'으로 + # 둔다(IBC Code 3.1.3: 격리해 실으면 한 선박 적재는 허용). 예전엔 같은 배 화물끼리도 + # '→ 배정불가'로 올라, 같은 카드에 '배정불가'와 판정 '주의'가 나란히 떴다(GOLDEN DENISE). + ships = carriers.get(target["chem_id"], set()) | carriers.get(other_id, set()) + onboard_only = ( + len(ships) == 1 and None not in ships + and carriers.get(target["chem_id"]) == carriers.get(other_id) + ) + if onboard_only: + floor = RiskLevel.CAUTION + target_name = target["cargo_name"] or target["chem_id"] other_name = raw.get("name_ko") or name_of.get(other_id, other_id) if "segregation_code" in raw: why = f"IMDG 격리코드 {raw['segregation_code']}" elif "reason" in raw: - why = f"벌크호환성그룹(참고) — {raw['reason']}" + # 46 CFR 150 호환성은 안전 에이전트 판정의 근거다(verdict_basis) — '참고'가 아니다. + why = f"46 CFR 150 호환성 그룹 — {raw['reason']}" else: why = f"혼재금지({raw.get('category', '분류 미상')})" + if onboard_only: + why += " (같은 선박 적재 — 격리 적재 확인 필요)" pairs_by_berth.setdefault(berth, []).append({ "level": _RISK_TO_ALERT[floor], @@ -450,15 +468,25 @@ async def _draught_alerts(db: AsyncSession) -> list[dict]: # 관제사가 알아야 하는 건 '배정 실패'가 아니라 **'지금 자리가 조건에 안 맞는 배'** 다. # # 배 1척당 최신 판정 1건만 본다. 이력 전체를 펼치면 같은 배가 화면에 여러 번 뜬다. +# +# [2026-09-29] 최신 판정을 **먼저** 고른 뒤 '적합'을 거른다. 예전엔 거꾸로라, 판정불가 → 적합으로 +# 바뀐 배의 옛 판정불가가 경고로 남았다(실측: STI SUPREME — 선박 판정 표는 '적합', 경고는 '판정불가'). +# '현재'의 조건은 approvals._QUERY_PENDING 과 같다 — 항내에 있거나 24시간 안에 판정된 배. _QUERY_ACTIVE_ASSESSMENTS = text(""" - SELECT DISTINCT ON (call_sign) - call_sign, vessel_name, stage, wharf_name, level, action, recipient, - reasons, assessed_at_utc - FROM assessment_history + WITH latest AS ( + SELECT DISTINCT ON (call_sign) + call_sign, vessel_name, stage, wharf_name, level, action, recipient, + reasons, assessed_at_utc + FROM assessment_history + ORDER BY call_sign, assessed_at_utc DESC + ), present AS ( + SELECT DISTINCT upper(btrim(callsgn)) AS cs FROM mart.vessel_presence + ) + SELECT * FROM latest l WHERE level <> '적합' - -- 오래된 판정은 경고가 아니라 이력이다. 그 배는 이미 떠났을 수 있다. - AND assessed_at_utc > now() - interval '24 hours' - ORDER BY call_sign, assessed_at_utc DESC + -- 오래된 판정은 경고가 아니라 이력이다 — 그 배가 아직 항내에 있을 때만 남긴다. + AND (assessed_at_utc > now() - interval '24 hours' + OR EXISTS (SELECT 1 FROM present p WHERE p.cs = upper(btrim(l.call_sign)))) """) # 판정 등급 -> 경고 심각도. '판정불가'를 WARNING 으로 두는 것이 핵심이다 — @@ -491,7 +519,9 @@ async def _assessment_alerts(db: AsyncSession) -> list[dict]: for row in rows: who = row["vessel_name"] or row["call_sign"] where = row["wharf_name"] or "계류시설 미상" - head = (row["reasons"] or [""])[0] + # 9/29 이전 판정 문장은 "배정된 선석은…"이다(주어를 출처대로 바꾸기 전). 판정은 바뀔 때만 + # 기록되므로 그대로인 배는 옛 문장이 남는다 — 우리는 배정하지 않으므로 보일 때만 고친다(기록은 그대로). + head = (row["reasons"] or [""])[0].replace("배정된 선석", "이 선석") # 조치안이 있으면 받을 곳까지 적는다. 우리가 실행하지 않는다는 뜻이 문장에 남는다. tail = f" → {row['action']} 검토 필요({row['recipient']})" if row["action"] else "" out.append({ diff --git a/app/agents/safety/msds_context.py b/app/agents/safety/msds_context.py index fc10efb..a471454 100644 --- a/app/agents/safety/msds_context.py +++ b/app/agents/safety/msds_context.py @@ -47,6 +47,25 @@ async def resolve_cargo(db: AsyncSession, ref: CargoRef) -> MsdsChemical: return await get_by_chem_id(db, ref.chem_id) +def ghs_classification(msds_payload: dict | None) -> list[tuple[str, str]]: + """MSDS 2절 '유해성·위험성 분류'(B02)를 [(분류명, 구분), ...] 로 (2026-09-29). + + 원문 예: "인화성 액체 : 구분3|발암성 : 구분2|특정표적장기 독성(1회 노출) : 구분3(호흡기 자극)". + """ + data = ((msds_payload or {}).get("detail02") or {}).get("data") or [] + for item in data: + if item.get("msdsItemCode") != "B02": + continue + out = [] + for part in (item.get("itemDetail") or "").split("|"): + # 마지막 콜론으로 자른다 — 분류명 자체에 콜론이 있다("급성 독성(흡입: 증기) : 구분4"). + name, _, grade = part.rpartition(":") + if name.strip() and grade.strip(): + out.append((name.strip(), grade.strip())) + return out + return [] + + def summarize_hazard_sections(msds_payload: dict) -> dict[str, list[str]]: """관련 섹션에서 itemDetail 텍스트만 뽑아 { 섹션키: [문장, ...] } 형태로 반환.""" summary: dict[str, list[str]] = {} diff --git a/app/agents/safety/prompt.py b/app/agents/safety/prompt.py index 6197bdc..e64f41e 100644 --- a/app/agents/safety/prompt.py +++ b/app/agents/safety/prompt.py @@ -2,47 +2,38 @@ from .schemas import ( BulkCompatibilityConflict, IncompatibleConflict, + OnboardConflict, PackagingViolation, - RiskLevel, UnassessedPair, ) +# [2026-09-29] 등급 설명(reasoning)을 LLM 에서 걷어냈다. 예전 규칙 1("그 등급이 왜 그렇게 +# 나왔는지 설명하라")과 규칙 4("등급 설명은 쓰지 마라")가 부딪혔고, 입력의 대부분이 MSDS +# 유해성이라 모델이 등급의 원인을 유해성에서 찾았다 — 같은 선박 화물 충돌로 '주의'가 된 배를 +# "발암성 때문에 주의"라고 썼다(nano·mini 모두 재현). 등급의 이유는 코드(verdict_basis)가 +# 확정하고, LLM 에는 등급을 주지도 묻지도 않는다. SYSTEM_PROMPT = """\ -당신은 울산항 액체화물 하역 안전관제를 보조하는 AI입니다. -아래 규칙을 반드시 지켜 JSON으로만 응답하세요. - -1. ★ 위험등급은 이미 규칙엔진이 확정했습니다. 당신은 등급을 정하지 않습니다. - 입력의 "확정 위험등급"을 사실로 받아들이고, 그 등급이 왜 그렇게 나왔는지를 - 제공된 근거로 설명하세요. 등급을 다르게 판단하거나 "더 위험해 보인다"고 - 쓰지 마세요 — 화면에 표시되는 등급과 당신의 설명이 어긋나면 관제사가 - 무엇을 믿어야 할지 알 수 없게 됩니다. -2. checklist와 key_hazards는 반드시 입력으로 제공된 MSDS 발췌문 안의 내용에 - 근거해서만 작성하세요. 제공되지 않은 사실을 추측해서 만들어내지 마세요. -3. checklist는 5~8개, key_hazards는 2~5개의 짧고 실행 가능한 한국어 항목으로 작성하세요. -4. reasoning은 1문단으로 대상 화물의 위험성(MSDS 발췌 근거)을 설명하고, 충돌이나 - 판정 근거 부족이 있으면 어떤 화물과 어떤 카테고리·그룹 때문인지 적으세요. - ★ "어떤 검사를 했고 충돌이 없었다"는 요약과 등급 설명은 쓰지 마세요 — 코드가 - 확정 문장으로 앞에 붙입니다. 입력에 없는 조치·이행 상황("조치가 이행 중")도 쓰지 마세요. -5. "포장·하역방식 부적합"은 인접 화물과 무관하게 대상 화물 자신의 신고 내용만 - 보는 판정입니다(용기등급 대비 하역방식). 이게 있다면 인접 선석 충돌과는 - 별개 문제로 명확히 구분해서 reasoning에 적으세요. -6. "벌크 액체화학물질 호환성 그룹 충돌"은 미국 해안경비대 규정 46 CFR Part 150의 - 산적 액체화물 호환성 차트(인접 탱크 기준)와 그 예외표에서 옮긴 것입니다. - reasoning에서 "IMDG Code"나 "국내 법령"인 것처럼 표현하지 말고, "46 CFR Part 150 - 호환성 차트 기준"이라고 정확히 표현하세요. -7. "혼재금지 판정 근거 부족" 항목에 화물이 실려 있으면, 그 화물에 대한 - "충돌 없음"은 **안전이 확인된 것이 아니라 확인할 자료가 없는 것**입니다. - reasoning에서 "충돌이 없어 안전하다"고 쓰지 말고 "판정 근거가 없어 확인이 - 필요하다"고 정확히 구분해 쓰고, checklist에 자료 보완·전문가 확인 항목을 - 하나 넣으세요. 근거가 없는 것을 안전으로 단정하지 않는 것이 이 시스템의 - 원칙입니다. -8. 이 판정은 **서로 다른 부두에 접안한 선박 사이**의 문제입니다. IMDG Code의 - 격리 규정은 단일 선박 내 화물 적부 기준(이격거리 3~24m)이라 부두 간에는 - 적용 대상이 아니므로, IMDG 격리코드를 부두 간 배치의 근거로 인용하지 - 마세요. 위 입력에도 IMDG 항목은 제공되지 않습니다. -9. 입력에 "검사하지 않음" 또는 "인접 화물 없음"이라고 적힌 항목은 **검증된 것이 - 아닙니다.** "검증이 이루어졌다", "적합하게 수행되고 있다", "충돌 없음으로 확인됐다"처럼 - 쓰지 말고, 하지 않은 검사는 하지 않았다고 그대로 쓰세요. +당신은 울산항 액체화물 하역 관제 화면에 들어갈 화물 설명과 하역 전 확인 항목을 쓰는 보조자입니다. +JSON으로만 응답하세요. 등급과 그 이유는 규칙 엔진이 정해 화면에 따로 표시합니다 — +당신은 등급('안전'·'주의'·'위험'·'배정불가')을 말하거나 설명하지 않습니다. + +1. cargo_profile — 이 화물이 하역 현장(부두·로딩암·탱크)에서 무엇이 위험한지 1~2문장, 100자 이내. + [대상 화물 MSDS 발췌]만 근거로 쓰세요. 등급·충돌 여부·검사 여부는 쓰지 마세요. + [대상 화물]이 여러 종('·'로 이어짐)이면 화물마다 나열하지 말고, 공통 위험과 특히 두드러진 화물을 + 묶어 1~2문장, 150자 이내로 쓰세요. key_hazards·checklist 도 그 화물들 전체를 대상으로 고르세요. +2. key_hazards — 핵심 유해성 2~5개. 각각 15자 안팎의 명사구. MSDS 발췌만 근거로. +3. checklist — 하역 전에 관제사·터미널이 확인할 항목 3~5개, 각각 한 문장. + - [이번 판정에서 확인할 것]에 항목이 있으면 그것부터 빠짐없이 확인 항목으로 옮기세요. + - 나머지는 MSDS 발췌에서 이 화물의 하역 작업에 특히 필요한 것만 고르세요. + - 이 화물은 탱커가 로딩암·호스로 탱크에 벌크 하역합니다. 용기·드럼·포장 취급을 전제로 한 항목은 + 쓰지 마세요("용기 밀폐 상태 확인" 등). + - 어느 화물에나 붙는 일반 수칙은 쓰지 마세요: 보호구 착용, MSDS 숙지, 폐기물 처리, 환기 확인, + 방폭 설비 확인, 스파크 없는 도구 사용, 점화원 제거 같은 문장. 이 화물만의 수치·조건 + (인화점·증기압·반응 상대·온도 관리 등)이 들어간 항목을 쓰세요. +4. 입력에 없는 사실·조치·이행 상황을 만들지 마세요. "검사하지 않음"·"인접 화물 없음"인 항목을 + 검증된 것처럼 쓰지 마세요. +5. 46 CFR Part 150 호환성 차트 근거는 "46 CFR 150 호환성 차트"라고 쓰고 IMDG Code·국내 법령으로 + 부르지 마세요. IMDG 격리 규정은 한 선박 안의 적부 기준이라 부두 간 배치의 근거가 아닙니다. """ @@ -121,6 +112,16 @@ def _format_packaging_violations( return f"(부적합 없음 — 신고된 하역방식 '{unload_method_name}' 기준)" +def _format_onboard(onboard: list[OnboardConflict]) -> str: + if not onboard: + return "(충돌 없음 — 또는 화물이 1종이라 비교 대상 없음)" + return "\n".join( + f" - {c.cargo_a_name} ↔ {c.cargo_b_name}: {c.basis} {c.detail} " + "(한 선박 적재 자체는 격리 시 허용 — 격리 적재 여부 확인 필요)" + for c in onboard + ) + + # [2026-09-27] 이웃이 0건이어도 예전엔 "충돌 없음"·"판정 근거가 확보됨"으로 넘겨, LLM 이 # "인접 선석 충돌 없음으로 확인"이라고 썼다. 비교할 이웃이 없었다는 사실을 그대로 적는다. _NO_NEIGHBORS = "(인접 화물 없음 — 비교할 이웃 화물이 없어 혼재 검사를 하지 않았습니다)" @@ -134,11 +135,30 @@ def build_user_prompt( bulk_compatibility_conflicts: list[BulkCompatibilityConflict], packaging_violations: list[PackagingViolation], unassessed_pairs: list[UnassessedPair], - rule_engine_floor: RiskLevel, adjacent_count: int, unload_method_name: str | None, + onboard_conflicts: list[OnboardConflict] | None = None, + needs_check: list[str] | None = None, + conflict_checks: list[str] | None = None, ) -> str: + """conflict_checks — 배가 실은 **모든 화물**의 충돌 줄(화물 이름 포함, service._verdict_basis). + + [2026-09-29] 주면 대표 화물의 충돌 목록 대신 이것을 '확인할 것'에 쓴다. 예전엔 대표 화물 것만 + 넘겨, 다른 화물의 충돌이 체크리스트에서 빠졌다. 아래 충돌 절(대표 화물 기준)은 그대로 둔다. + """ no_neighbors = adjacent_count == 0 + if conflict_checks is None: + conflict_checks = ( + [f"이웃 {c.adjacent_berth} {c.adjacent_name}와 '{c.shared_category}' 혼재금지 충돌" for c in conflicts] + + [f"이웃 {c.adjacent_berth} {c.adjacent_name}와 46 CFR 150 호환성 충돌 — {c.reason}" + for c in bulk_compatibility_conflicts] + + [f"포장·하역방식 부적합 — {v.reason}" for v in packaging_violations] + ) + to_check = ( + [f"{c.cargo_a_name} ↔ {c.cargo_b_name} 같은 선박 적재 — 격리 적재 확인" for c in onboard_conflicts or []] + + list(conflict_checks) + + list(needs_check or []) + ) return f"""\ [대상 화물] {target_cargo_name} @@ -146,7 +166,7 @@ def build_user_prompt( [대상 화물 MSDS 발췌] {_format_hazard_summary(hazard_summary)} -[인접 선석 혼재금지 충돌 (Neo4j 그래프 탐색 결과, MSDS 텍스트 기반)] +[인접 선석 혼재금지 충돌 — 대상 화물 기준 (Neo4j 그래프 탐색 결과, MSDS 텍스트 기반)] {_NO_NEIGHBORS if no_neighbors else _format_conflicts(conflicts)} [벌크 액체화학물질 호환성 그룹 충돌 (46 CFR Part 150 호환성 차트 기준)] @@ -158,10 +178,11 @@ def build_user_prompt( [혼재금지 판정 근거 부족 — ★ 위의 "충돌 없음"을 "안전 확인"으로 읽으면 안 되는 화물] {_format_unassessed(unassessed_pairs, adjacent_count)} -[확정 위험등급 — 규칙엔진이 결정했으며 변경 대상이 아님] -{rule_engine_floor.value} +[같은 선박 내 화물끼리의 혼재 충돌 (이 배가 함께 실은 화물 간)] +{_format_onboard(onboard_conflicts or [])} + +[이번 판정에서 확인할 것] +{chr(10).join(f" - {t}" for t in to_check) or "(없음)"} -위 정보를 바탕으로 checklist, key_hazards, reasoning을 JSON으로 응답하세요. -reasoning은 "확정 위험등급"이 그렇게 나온 이유를 제공된 근거로 설명하는 글입니다. -포장·하역방식 부적합이 있다면 reasoning과 checklist에 그 내용을 반드시 반영하세요. +위 정보를 바탕으로 cargo_profile, key_hazards, checklist를 JSON으로 응답하세요. """ diff --git a/app/agents/safety/safety_index.py b/app/agents/safety/safety_index.py index 557c7cb..829f55c 100644 --- a/app/agents/safety/safety_index.py +++ b/app/agents/safety/safety_index.py @@ -105,24 +105,24 @@ def _axis(subject: str, score: float | None, basis: str) -> dict: async def build_safety_index(db: AsyncSession, driver: AsyncDriver) -> dict: axes: list[dict] = [] - # 1. 기상 여유 — 임계값까지 얼마나 남았나 (풍속·파고 중 나쁜 쪽) + # 1. 기상 여유 — 풍속이 중단 임계까지 얼마나 남았나. + # [2026-09-29] 파고는 점수에서 뺐다. 우리 파고는 외해 부이(22189) 관측 하나라 항내 부두에는 + # 적용하지 않는다(weather.rule_engine.wave_applies_to) — 여기서만 대입해, 풍속 2.1m/s 로 + # 멀쩡한데 파고 1.4m 하나로 이 축이 6.7점이었다(실측). 값은 참고로 적어 둔다. th = (await db.execute(_Q_GLOBAL_THRESHOLD)).mappings().first() wx = (await db.execute(_Q_WEATHER)).mappings().first() - if th and wx and (wx["wind_speed_ms"] is not None or wx["wave_height_sig_m"] is not None): - used = [] - if wx["wind_speed_ms"] is not None and th["stop_wind_ms"]: - used.append(wx["wind_speed_ms"] / float(th["stop_wind_ms"])) - if wx["wave_height_sig_m"] is not None and th["stop_wave_m"]: - used.append(wx["wave_height_sig_m"] / float(th["stop_wave_m"])) - worst = max(used) if used else None + if th and wx and wx["wind_speed_ms"] is not None and th["stop_wind_ms"]: + wave_note = ( + f" · 파고 {wx['wave_height_sig_m']} m 는 외해 부이 관측이라 항내 부두에 적용하지 않음(참고)" + if wx["wave_height_sig_m"] is not None else "" + ) axes.append(_axis( "기상 여유", - None if worst is None else (1 - worst) * 100, - f"풍속 {wx['wind_speed_ms']} m/s (중단 {th['stop_wind_ms']}), " - f"파고 {wx['wave_height_sig_m']} m (중단 {th['stop_wave_m']})", + (1 - wx["wind_speed_ms"] / float(th["stop_wind_ms"])) * 100, + f"풍속 {wx['wind_speed_ms']} m/s (중단 {th['stop_wind_ms']}){wave_note}", )) else: - axes.append(_axis("기상 여유", None, "관측값 없음 — 판정 불가")) + axes.append(_axis("기상 여유", None, "풍속 관측값 없음 — 판정 불가")) # 2. 흘수 여유 — 접안 불가/경계 판정이 얼마나 섞여 있나. # UNKNOWN(부두 제원 미확보)과 CHECK(선석별 수심이 달라 어느 선석인지 확인 필요)는 diff --git a/app/agents/safety/schemas.py b/app/agents/safety/schemas.py index 95051f7..47392f7 100644 --- a/app/agents/safety/schemas.py +++ b/app/agents/safety/schemas.py @@ -98,6 +98,11 @@ class SafetyAssessmentRequest(BaseModel): "비우면 target_cargo 하나만 본다(하위 호환).", ) adjacent_cargos: list[AdjacentCargo] = Field(default_factory=list) + call_sign: str | None = Field( + default=None, + description="[2026-09-29] 대상 화물을 실은 배의 호출부호. 주면 이번 입항 건 화물 신고의 " + "하역방식(unload_method_name)을 채운다 — 비어 있는 화물만. 화물만 골라 묻는 경우엔 비운다.", + ) def all_targets(self) -> list[CargoRef]: """target_cargo + target_cargos (같은 식별자는 한 번만).""" @@ -206,6 +211,22 @@ class BulkCompatibilityConflict(BaseModel): reason: str +class OnboardConflict(BaseModel): + """같은 선박이 이번 입항에 함께 실은 화물끼리의 혼재 충돌 (2026-09-28 추가). + + 인접 선석 축과 같은 근거(MSDS 혼재금지·46 CFR 150 호환성 그룹)로 본다. 다만 한 선박 + 안에서는 탱크를 격리하면 함께 실을 수 있으므로(IBC Code 3.1.3) 규정 위반이 아니라 + "격리 적재 확인 필요"다 — 배의 등급을 최소 '주의'로만 올린다(배정불가 아님). + """ + + cargo_a_chem_id: str + cargo_a_name: str + cargo_b_chem_id: str + cargo_b_name: str + basis: str = Field(description="'46 CFR 150' 또는 'MSDS'") + detail: str = Field(description="충돌 근거 — 호환성 그룹 쌍 또는 혼재금지 카테고리") + + class PackagingViolation(BaseModel): """포장·하역방식 부적합. IncompatibleConflict/ImdgSegregationConflict와 달리 인접 화물이 아니라 대상 화물 자신의 신고 내용(용기등급 vs 하역방식)만으로 판정한다.""" @@ -234,11 +255,18 @@ class LLMAssessment(BaseModel): 것 — MSDS 근거에 기반한 서술 — 만 맡는다. 부수 효과로 등급이 결정적이 되어 같은 입력에 항상 같은 등급이 나오고, LLM을 기다리지 않고도 등급을 확정할 수 있다(45ms, /safety/verdict). + + [2026-09-29] reasoning(등급 설명)을 cargo_profile(화물 특성)로 바꿨다. "등급이 왜 이렇게 + 나왔나"를 LLM 에 쓰게 했더니, 입력의 대부분인 MSDS 유해성을 원인으로 댔다 — 같은 선박 + 화물 충돌로 '주의'가 된 배를 "발암성 때문에 주의"라고 썼다(nano·mini 모두 재현). + 등급의 이유는 코드가 verdict_basis 로 확정하고, LLM 은 화물 특성과 체크리스트만 쓴다. """ - checklist: list[str] = Field(description="화물 맞춤형 안전 체크리스트 (MSDS 문구 근거)") + checklist: list[str] = Field(description="이번 판정 결과와 MSDS 에 맞춘 하역 전 확인 항목 (3~5개)") key_hazards: list[str] = Field(description="핵심 유해성 요약 (2~5개)") - reasoning: str = Field(description="판단 근거 요약 (관제사가 읽을 한두 문단)") + cargo_profile: str = Field( + description="이 화물이 하역 현장에서 무엇이 위험한지 1~2문장. 등급·충돌·검사 여부는 쓰지 않는다" + ) class SafetyVerdict(BaseModel): @@ -266,6 +294,16 @@ class SafetyVerdict(BaseModel): cargo_verdicts: list[CargoVerdictSummary] = Field( default_factory=list, description="화물별 판정 요약. 화물이 하나면 원소도 하나다." ) + onboard_conflicts: list[OnboardConflict] = Field( + default_factory=list, description="같은 선박 화물끼리의 혼재 충돌. 있으면 등급이 최소 '주의'." + ) + adjacent_count: int = Field(default=0, description="비교한 이웃 화물 수. 0 이면 혼재 비교 대상이 없었다") + verdict_basis: list[str] = Field( + default_factory=list, description="[2026-09-29] 등급의 근거 — 코드가 판정 결과에서 만든 문장" + ) + needs_check: list[str] = Field( + default_factory=list, description="[2026-09-29] 확인 필요 — 판정에 쓰지 못한 근거('대상 — 이유')" + ) class SafetyAssessmentResult(BaseModel): @@ -302,7 +340,7 @@ class SafetyAssessmentResult(BaseModel): "원인은 대개 KOSHA MSDS의 J08('피해야 할 물질')이 '자료없음'인 것으로, " "2026-08-23 API 원천 확인 결과 36종 중 27종이 해당한다.", ) - rule_engine_floor: RiskLevel = Field(description="그래프 탐색 기반 결정적 하한 등급 (MSDS 텍스트 + 벌크 호환성그룹 + 포장기준 + 판정가능성 중 가장 심각한 쪽)") + rule_engine_floor: RiskLevel = Field(description="그래프 탐색 기반 결정적 하한 등급 (MSDS 텍스트 + 벌크 호환성그룹 + 포장기준 + 판정가능성 + 같은 선박 혼재 중 가장 심각한 쪽)") msds_sections_used: list[str] = Field(description="프롬프트 근거로 사용된 MSDS detail 섹션 키 목록") imdg_classes: dict[str, str] = Field( default_factory=dict, @@ -316,3 +354,20 @@ class SafetyAssessmentResult(BaseModel): default_factory=list, description="[2026-09-25] 화물별 판정 요약. 최상위 필드는 is_governing=True 인 화물 기준이다.", ) + onboard_conflicts: list[OnboardConflict] = Field( + default_factory=list, + description="[2026-09-28] 같은 선박 화물끼리의 혼재 충돌. 있으면 risk_level 이 최소 '주의'" + "(격리 적재 확인 필요 — IBC Code 3.1.3 상 격리하면 함께 실을 수 있어 배정불가는 아니다).", + ) + adjacent_count: int = Field(default=0, description="비교한 이웃 화물 수. 0 이면 혼재 비교 대상이 없었다") + verdict_basis: list[str] = Field( + default_factory=list, + description="[2026-09-29] 등급의 근거 — 코드가 판정 결과에서 만든 문장. 화면은 이걸 등급 바로 " + "아래에 둔다(LLM 서술에 맡기지 않는다).", + ) + needs_check: list[str] = Field( + default_factory=list, description="[2026-09-29] 확인 필요 — 판정에 쓰지 못한 근거('대상 — 이유')" + ) + cargo_profile: str = Field( + default="", description="[2026-09-29] LLM 이 쓴 화물 특성 1~2문장(참고). 등급의 이유가 아니다" + ) diff --git a/app/agents/safety/service.py b/app/agents/safety/service.py index 48d21ff..011bf95 100644 --- a/app/agents/safety/service.py +++ b/app/agents/safety/service.py @@ -16,10 +16,11 @@ """ import asyncio -from dataclasses import dataclass +import re +from dataclasses import dataclass, field from neo4j import AsyncDriver -from sqlalchemy import select +from sqlalchemy import select, text from sqlalchemy.ext.asyncio import AsyncSession from app.llm.base import LLMClient @@ -37,10 +38,11 @@ find_incompatible_conflicts, find_live_categories, ) -from .msds_context import resolve_cargo, summarize_hazard_sections +from .msds_context import ghs_classification, resolve_cargo, summarize_hazard_sections from .prompt import SYSTEM_PROMPT, build_user_prompt from .stabilized_cargo import with_stabilized_checklist from .rule_engine import ( + PACKING_GROUP_HIGH_RISK, Assessability, compute_assessability_floor, compute_bulk_compatibility_floor, @@ -59,6 +61,7 @@ ImdgUnconfirmedPair, IncompatibleConflict, LLMAssessment, + OnboardConflict, PackagingViolation, RiskLevel, SafetyAssessmentRequest, @@ -172,32 +175,105 @@ class _Verdict: imdg_classes: dict[str, str] hazard_summary: dict[str, list[str]] unload_method_name: str | None = None # 포장 검사를 했는지 LLM 입력에 적는다 + adjacent_names: list[str] = field(default_factory=list) # 비교한 이웃 화물 이름(중복 제거, 2026-09-29) + + +_ONBOARD_LABEL = "(같은 선박)" + + +async def _compute_onboard_conflicts( + db: AsyncSession, neo4j_driver: AsyncDriver, verdicts: list["_Verdict"], +) -> list[OnboardConflict]: + """같은 선박이 함께 실은 화물끼리의 혼재 충돌 (2026-09-28). + + 인접 선석 판정과 **같은 함수**(_compute_verdict)에 나머지 화물을 이웃으로 넣어 돌리고, + 실제 충돌(MSDS 혼재금지·46 CFR 150 호환성 그룹)만 모은다. '근거 부족'(unassessed)은 + 쓰지 않는다 — 한 배 안의 화물쌍은 인접 선석보다 훨씬 많아 그것까지 경고로 올리면 + 다화물 선박이 거의 전부 '주의'가 되고, 실제 충돌이 묻힌다. + 화물쌍은 방향과 무관하게 한 번만 적는다(A↔B 와 B↔A 는 같은 쌍). + """ + if len(verdicts) < 2: + return [] + names = {v.target_row.chem_id: _cargo_display_name(v.target_row) for v in verdicts} + seen: set[tuple[frozenset[str], str, str]] = set() + out: list[OnboardConflict] = [] + + def add(a: str, b: str, basis: str, detail: str) -> None: + key = (frozenset((a, b)), basis, detail) + if key in seen: + return + seen.add(key) + out.append(OnboardConflict( + cargo_a_chem_id=a, cargo_a_name=names[a], cargo_b_chem_id=b, cargo_b_name=names[b], + basis=basis, detail=detail, + )) + + for v in verdicts: + a = v.target_row.chem_id + others = [ + AdjacentCargo(berth_name=_ONBOARD_LABEL, cargo=CargoRef(chem_id=o.target_row.chem_id)) + for o in verdicts if o.target_row.chem_id != a + ] + ov = await _compute_verdict( + db, neo4j_driver, SafetyAssessmentRequest(target_cargo=CargoRef(chem_id=a), adjacent_cargos=others), + ) + for c in ov.bulk_compatibility_conflicts: + groups = sorted([(c.target_group, c.target_group_name), (c.adjacent_group, c.adjacent_group_name)]) + add(a, c.adjacent_chem_id, "46 CFR 150", " ↔ ".join(f"{n}({g})" for g, n in groups)) + for c in ov.conflicts: + add(a, c.adjacent_chem_id, "MSDS", f"{c.shared_category} 혼재금지") + return out + + +# [2026-09-29] 하역방식은 msds 가 아니라 화물 신고(입항 건)마다 다른 값이다. 호출부가 넘기지 않아 +# 신고에 '펌프'가 다 있는데도 용기등급 Ⅰ 화물마다 "하역방식 신고가 없어 보지 못했습니다"가 붙었다 +# (실측 6시간 51건 중 20건, 혼재 심사 페이지도 같음). +# 입항 건은 고정하지 않고, 이 배의 신고 중 **그 화물이 실린 가장 최근 입항 건**에서 가져온다. +# mart.vessel_current_call 로 한정했더니 '지금+12시간 이내 입항'만 보는 뷰라, 다음 입항 예정 건의 +# 화물(화면·콘솔이 판정하는 화물)과 어긋나 못 찾았다(실측 갤럭시 삼부: 판정 화물은 9/30 입항 건, +# 뷰는 9/25 건). 판정 대상 화물은 호출부가 정하므로 여기선 chem_id 로 맞추기만 한다. +_SQL_UNLOAD_METHODS = text(""" + SELECT DISTINCT ON (cm.chem_id) cm.chem_id, cm.unload_method_name + FROM mart.cargo_msds cm + WHERE cm.callsgn = upper(btrim(:cs)) AND cm.chem_id IS NOT NULL + AND nullif(btrim(cm.unload_method_name), '') IS NOT NULL + ORDER BY cm.chem_id, cm.entry_year DESC, cm.entry_count DESC, cm.bl_no +""") + + +async def _unload_methods(db: AsyncSession, call_sign: str) -> dict[str, str]: + """화물(chem_id)별 신고 하역방식 — 이 배가 그 화물을 실은 가장 최근 입항 건 기준.""" + rows = (await db.execute(_SQL_UNLOAD_METHODS, {"cs": call_sign})).all() + return {r.chem_id: r.unload_method_name.strip() for r in rows} async def _compute_verdicts( db: AsyncSession, neo4j_driver: AsyncDriver, request: SafetyAssessmentRequest, -) -> tuple[_Verdict, list[CargoVerdictSummary]]: +) -> tuple[_Verdict, list[_Verdict], list[CargoVerdictSummary], list[OnboardConflict], RiskLevel]: """배가 실은 화물마다 판정하고, 가장 위험한 화물의 판정을 대표로 돌려준다. [2026-09-25] 전에는 target_cargo 하나만 봤다. 입항 판정이 그 배의 화물 여러 개 중 ORDER BY 없는 LIMIT 1 로 하나를 골랐기 때문에, 메탄올이 옆 배 화물과 충돌해도 벤젠이 뽑히면 '안전'이 나왔다. 이제 화물마다 같은 판정 함수를 돌린다(화물당 - 실측 약 45ms). 등급이 같으면 요청 순서상 앞 화물을 대표로 쓴다. + 실측 약 45ms). 등급이 같을 때 대표를 고르는 순서는 _pick_governing. + + [2026-09-28] 같은 선박 화물끼리도 비교한다(_compute_onboard_conflicts). 충돌이 있으면 + 배의 등급(네 번째 반환값)을 최소 '주의'로 올린다 — 격리 적재 확인 필요이지 배정불가가 + 아니다(IBC Code 3.1.3). 화물별 등급(cargo_verdicts)은 인접 선석 기준 그대로다. """ verdicts: list[_Verdict] = [] seen_chem: set[str] = set() + methods = await _unload_methods(db, request.call_sign) if request.call_sign else {} for cargo in request.all_targets(): - verdict = await _compute_verdict(db, neo4j_driver, request, target_cargo=cargo) + verdict = await _compute_verdict(db, neo4j_driver, request, target_cargo=cargo, unload_methods=methods) # 같은 물질이 다른 키(cas_no 만 / chem_id)로 두 번 올 수 있다 — 해석된 chem_id 로 한 번만. if verdict.target_row.chem_id in seen_chem: continue seen_chem.add(verdict.target_row.chem_id) verdicts.append(verdict) - governing = max( - enumerate(verdicts), key=lambda iv: (risk_level_rank(iv[1].rule_engine_floor), -iv[0]) - )[1] + governing = _pick_governing(verdicts) summaries = [ CargoVerdictSummary( target_cargo_name=_cargo_display_name(v.target_row), @@ -211,7 +287,34 @@ async def _compute_verdicts( ) for v in verdicts ] - return governing, summaries + onboard = await _compute_onboard_conflicts(db, neo4j_driver, verdicts) + ship_level = max_risk_level( + governing.rule_engine_floor, RiskLevel.CAUTION if onboard else RiskLevel.SAFE, + ) + return governing, verdicts, summaries, onboard, ship_level + + +# 용기등급이 높을수록(Ⅰ 이 가장 위험) 앞선다. 규칙 엔진과 같은 표기('Ⅰ')만 센다. +_PACKING_RANK = {PACKING_GROUP_HIGH_RISK: 3, "Ⅱ": 2, "Ⅲ": 1} + + +def _pick_governing(verdicts: list[_Verdict]) -> _Verdict: + """대표 화물 — 등급이 가장 높은 화물. 같으면 용기등급, 그다음 인화점이 낮은 쪽 (2026-09-29). + + 대표 화물의 MSDS 로 LLM 이 화물 특성·체크리스트를 쓴다. 예전엔 등급이 같으면 요청 순서상 + 앞 화물을 골라, 6종이 모두 '안전'인 배에서 케로젠(Ⅲ)이 대표가 되고 가솔린(인화점 -40℃)· + 용기등급 Ⅰ 화물의 유해성은 화면 어디에도 없었다. 모두 같으면 요청 순서상 앞 화물이다. + """ + def key(iv: tuple[int, _Verdict]): + i, v = iv + fp = v.target_row.flash_point_celsius + return ( + risk_level_rank(v.rule_engine_floor), + _PACKING_RANK.get(v.target_row.packing_group, 0), + -fp if fp is not None else float("-inf"), + -i, + ) + return max(enumerate(verdicts), key=key)[1] async def _compute_verdict( @@ -220,10 +323,17 @@ async def _compute_verdict( request: SafetyAssessmentRequest, *, target_cargo: CargoRef | None = None, + unload_methods: dict[str, str] | None = None, ) -> _Verdict: - """화물 하나에 대해 LLM을 부르지 않고 등급과 근거를 확정한다.""" + """화물 하나에 대해 LLM을 부르지 않고 등급과 근거를 확정한다. + + unload_methods — 이번 입항 건 신고의 chem_id별 하역방식(_unload_methods). 요청에 하역방식이 + 없는 화물만 채운다. 해석된 chem_id 로 찾으므로 CAS 로만 온 화물도 맞는다. + """ target_cargo = target_cargo or request.target_cargo target_row = await resolve_cargo(db, target_cargo) + if not target_cargo.unload_method_name and (unload_methods or {}).get(target_row.chem_id): + target_cargo = target_cargo.model_copy(update={"unload_method_name": unload_methods[target_row.chem_id]}) # [2026-08-23] 인접 화물 해석을 배치로 바꿨다. 이전에는 리스트 컴프리헨션 # 안에서 화물마다 순차 await 했는데, 실제 스케줄링 응답의 인접 화물이 9종까지 @@ -472,6 +582,7 @@ async def _compute_verdict( imdg_classes=imdg_classes, hazard_summary=hazard_summary, unload_method_name=target_cargo.unload_method_name, + adjacent_names=list(dict.fromkeys(row.name_ko or row.chem_id for _, _, row in adjacent_resolved)), ) @@ -487,11 +598,11 @@ async def assess_verdict( /safety/assess가 돌려주는 risk_level과 **항상 같다** — 실측으로도 격상률이 0%다(48회). 먼저 보여준 등급이 뒤집히지 않으므로 조기 표시가 안전하다. """ - v, summaries = await _compute_verdicts(db, neo4j_driver, request) + v, verdicts, summaries, onboard, ship_level = await _compute_verdicts(db, neo4j_driver, request) return SafetyVerdict( - target_cargo_name=_cargo_display_name(v.target_row), - risk_level=v.rule_engine_floor, - rule_engine_floor=v.rule_engine_floor, + target_cargo_name=_focus_name(_focus(verdicts, onboard)), + risk_level=ship_level, + rule_engine_floor=ship_level, conflicts=v.conflicts, imdg_conflicts=v.imdg_conflicts, imdg_unconfirmed_pairs=v.imdg_unconfirmed_pairs, @@ -501,40 +612,282 @@ async def assess_verdict( msds_sections_used=list(v.hazard_summary.keys()), imdg_classes=v.imdg_classes, cargo_verdicts=summaries, + onboard_conflicts=onboard, + adjacent_count=len(request.adjacent_cargos), + verdict_basis=_verdict_basis(verdicts, len(request.adjacent_cargos), onboard), + needs_check=_needs_check(verdicts), ) -def _checked_facts(v: _Verdict, adjacent_count: int) -> str: +def _adjacent_label(verdicts: list[_Verdict], adjacent_count: int) -> str: + """'이웃 화물 5건(가솔린, 케로젠 외 1종)' — 몇 건인지만 말하지 말고 무엇인지 적는다(2026-09-29).""" + names = getattr(verdicts[0], "adjacent_names", []) if verdicts else [] + if not names: + return f"이웃 화물 {adjacent_count}건" + shown = ", ".join(names[:5]) + (f" 외 {len(names) - 5}종" if len(names) > 5 else "") + return f"이웃 화물 {adjacent_count}건({shown})" + + +# ── 주요 위험성 — MSDS GHS 분류에서 코드가 만든다 (2026-09-29) ── +# LLM 에 여러 화물을 한 번에 쓰게 했더니 가장 센 물질의 특성을 전부에 씌웠다(실측: 디젤·케로젠(인화성 +# 구분3)을 '고인화성', 네 물질 모두 분류에 없는 '눈 자극', 3종이 해당하는 흡인 유해성 누락). 분류는 +# MSDS 에 확정돼 있으니 그대로 옮긴다. 하역 현장에서 중요한 순서로 둔다. +_HAZARD_ORDER = ("인화성", "흡인 유해성", "발암성", "생식세포 변이원성", "생식독성", "흡입 독성", "급성 독성", + "특정표적장기 독성", "피부", "눈", "호흡기 과민성", "수생") +_FLAMMABLE_GRADE = {"구분1": "극인화성", "구분2": "고인화성", "구분3": "인화성", "구분4": "가연성"} +_SUSPECTED = ("발암성", "생식세포 변이원성", "생식독성") + + +def _hazard_label(name: str, grade: str) -> tuple[str, str]: + """(묶음 제목, 그 안의 구분 표기). 괄호를 겹치지 않게 '3, 호흡기 자극'처럼 쉼표로 편다.""" + if name.startswith("인화성 액체"): + return "인화성 액체", _FLAMMABLE_GRADE.get(grade, grade) + # '구분' 글자는 뺀다 — 항목마다 반복돼 읽기만 무거웠다(사용자 요청 9/29). '구분1' → '1'. + flat = grade.replace("구분", "").replace("(", ", ").replace(")", "").strip() + if name.startswith("급성 독성(흡입"): + route = name[name.find(":") + 1:].strip(" )") if ":" in name else "" + return "흡입 독성", f"{flat}, {route}" if route else flat + if name.startswith(_SUSPECTED): + return name, f"{flat}, 의심" if flat == "2" else flat + return name, flat + + +def _hazard_lines(focus: list[_Verdict]) -> list[str]: + """설명할 화물들의 GHS 분류를 항목별로 묶는다. 화물이 하나면 이름 없이 '인화성 액체 고인화성'. + + 한 항목 안에서는 센 구분이 앞에 온다(원문 '구분1' < '구분2' 순). + """ + per_label: dict[str, dict[str, tuple[str, list[str]]]] = {} + for x in focus: + for name, grade in ghs_classification(x.target_row.msds_payload): + label, shown = _hazard_label(name, grade) + per_label.setdefault(label, {}).setdefault(shown, (grade, []))[1].append(_cargo_display_name(x.target_row)) + + def rank(label: str) -> int: + return next((i for i, k in enumerate(_HAZARD_ORDER) if k in label), len(_HAZARD_ORDER)) + + lines = [] + for label in sorted(per_label, key=rank): + grades = sorted(per_label[label].items(), key=lambda kv: kv[1][0]) + if len(focus) == 1: + lines.append(f"{label} {' · '.join(g for g, _ in grades)}") + else: + lines.append(f"{label}: " + " · ".join(f"{'·'.join(names)}({g})" for g, (_, names) in grades)) + return lines + + +def _focus(verdicts: list[_Verdict], onboard: list[OnboardConflict]) -> list[_Verdict]: + """설명할 화물 — 안전이 아닌 화물만, 모두 안전이면 전부 (2026-09-29). + + 예전엔 대표 화물 하나로 설명해, 2종 모두 안전인 배도 "염화비닐 기준"으로만 읽혔다. 문제가 + 있으면 그 화물만 말하고, 없으면 실은 화물을 묶어 말한다. 인접 기준으로는 모두 안전인데 같은 + 선박 화물끼리 충돌한 배는 그 충돌에 걸린 화물이 대상이다. 위험한 화물이 앞에 온다. + """ + risky = [x for x in verdicts if x.rule_engine_floor is not RiskLevel.SAFE] + if not risky and onboard: + names = {c.cargo_a_name for c in onboard} | {c.cargo_b_name for c in onboard} + risky = [x for x in verdicts if _cargo_display_name(x.target_row) in names] + return sorted(risky or verdicts, key=lambda x: -risk_level_rank(x.rule_engine_floor)) + + +def _focus_name(focus: list[_Verdict]) -> str: + return "·".join(_cargo_display_name(x.target_row) for x in focus) + + +# 여러 화물 MSDS 를 한 번에 넘길 때 화물·절마다 앞 몇 줄만 — 6종이면 발췌가 여섯 배가 된다. +_MULTI_HAZARD_LINES = 3 + + +def _merged_hazards(focus: list[_Verdict]) -> dict[str, list[str]]: + """설명할 화물들의 MSDS 발췌를 절별로 합친다. 줄마다 어느 화물인지 붙인다.""" + if len(focus) == 1: + return focus[0].hazard_summary + merged: dict[str, list[str]] = {} + for x in focus: + name = _cargo_display_name(x.target_row) + for section, lines in x.hazard_summary.items(): + merged.setdefault(section, []).extend(f"[{name}] {line}" for line in lines[:_MULTI_HAZARD_LINES]) + return merged + + +def _checked_facts( + focus: list[_Verdict], adjacent_count: int, onboard: list[OnboardConflict], ship_level: RiskLevel, +) -> str: """무엇을 검사했고 결과가 무엇인지 — 코드가 확정하는 첫 문장. [2026-09-27] LLM 에 맡기면 이웃 0건·하역방식 미신고인 판정에서도 "인접 선석 충돌 없음으로 확인", "포장·하역방식 검증이 이루어졌으며", "기본 조치는 이행 중"처럼 하지 않은 검사를 했다고 썼다(입력 문구를 바로잡은 뒤에도 재현). 챗봇 결론 등급과 같은 - 원칙으로, 검사 사실은 근거에서 기계적으로 만든다. 여러 화물을 실은 배면 대표 화물 - 기준이다(나머지 화물 등급은 cargo_verdicts). + 원칙으로, 검사 사실은 근거에서 기계적으로 만든다. + [2026-09-29] 대표 화물 하나가 아니라 _focus 의 화물들 기준이다 — 모두 안전이면 전 화물 종합. """ - name = _cargo_display_name(v.target_row) + conflicts = [c for x in focus for c in x.conflicts] + bulk = [c for x in focus for c in x.bulk_compatibility_conflicts] parts: list[str] = [] if adjacent_count == 0: parts.append("인접 선석에 비교할 화물이 없어 혼재 검사 대상이 없었습니다") else: - n_conf = len({c.adjacent_chem_id for c in v.conflicts} - | {c.adjacent_chem_id for c in v.bulk_compatibility_conflicts}) + n_conf = len({c.adjacent_chem_id for c in conflicts} | {c.adjacent_chem_id for c in bulk}) if n_conf: parts.append(f"인접 화물 중 {n_conf}종과 충돌이 있습니다" - f"(MSDS {len(v.conflicts)}건 · 46 CFR 150 {len(v.bulk_compatibility_conflicts)}건)") + f"(MSDS {len(conflicts)}건 · 46 CFR 150 {len(bulk)}건)") else: - parts.append(f"인접 화물 {adjacent_count}건과 MSDS·46 CFR 150 기준 충돌이 없습니다") - n_un = len({p.adjacent_chem_id for p in v.unassessed_pairs}) + parts.append(f"{_adjacent_label(focus, adjacent_count)}과 MSDS·46 CFR 150 기준 충돌이 없습니다") + n_un = len({p.adjacent_chem_id for x in focus for p in x.unassessed_pairs}) if n_un: parts.append(f"그중 {n_un}종은 판정 근거가 없어 확인이 필요합니다") - if v.packaging_violations: + # [2026-09-29] 하역방식은 용기등급 Ⅰ 화물만 판정한다(_needs_check 와 같은 기준). 그 밖의 화물에 + # "하역방식이 신고되지 않아 검사하지 않았습니다"를 붙이면, 확인 필요에서 뺀 말이 여기서 다시 나왔다. + if any(x.packaging_violations for x in focus): parts.append("포장·하역방식 부적합이 있습니다") - elif not v.unload_method_name: - parts.append("포장·하역방식은 하역방식이 신고되지 않아 검사하지 않았습니다") - else: + elif any(x.target_row.packing_group == PACKING_GROUP_HIGH_RISK and not x.unload_method_name for x in focus): + parts.append("용기등급 Ⅰ 화물인데 하역방식이 신고되지 않아 포장·하역방식은 검사하지 않았습니다") + elif any(x.unload_method_name for x in focus): parts.append("포장·하역방식 부적합은 없습니다") - return f"[혼재 판정 {v.rule_engine_floor.value} · {name} 기준] " + ". ".join(parts) + "." + if onboard: + pairs = sorted({f"{c.cargo_a_name}↔{c.cargo_b_name}" for c in onboard}) + parts.append(f"같은 선박 화물 중 {len(pairs)}쌍이 혼재 충돌입니다({', '.join(pairs)}) — " + "격리 적재 여부 확인이 필요합니다") + # 머리에 물질 이름을 넣지 않는다 — 여러 종이면 이름 나열이 됐다(사용자 요청 9/29). 어느 화물인지는 + # 화물별 판정 줄과 확인 필요가 보여 준다. + scope = f" · 화물 {len(focus)}종 종합" if len(focus) > 1 and ship_level is RiskLevel.SAFE else "" + # 머리 뒤에서 줄을 바꾼다 — 한 줄에 붙어 있으면 머리와 본문이 구분되지 않았다(2026-09-29). + return f"[혼재 판정 {ship_level.value}{scope}]\n" + ". ".join(parts) + "." + + +# 어느 화물에나 붙는 일반 수칙의 낱말. 프롬프트로 금지해도 모델이 계속 썼다(9/29 실측: 가솔린 체크리스트 +# 4항목 중 "환기 상태를 점검", "금연 및 열점화원 제거" 2개). 벌크 하역에 '용기'도 맞지 않는다. +# [2026-09-29] '마개'·'개봉'도 용기 취급 말이다 — "개봉 전 마개가 조심스럽게 열렸는지 확인"(실측). +_GENERIC_CHECK_WORDS = ("보호구", "MSDS", "숙지", "폐기물", "환기", "방폭", "스파크", "점화원", "금연", "화기", "용기", + "마개", "개봉") + + +def _drop_generic_checklist(items: list[str], cargo_names: list[str]) -> list[str]: + """일반 수칙 낱말이 있고 **숫자도 화물 이름도 없는** 항목을 뺀다 (2026-09-29). + + 숫자(인화점·온도 등)나 화물 이름이 들어 있으면 이 화물에 맞춘 항목일 수 있어 남긴다. + '확인할 것'에서 옮겨 온 항목(충돌·확인 필요)은 화물·이웃 이름이 들어 있어 빠지지 않는다. + """ + def generic(item: str) -> bool: + return ( + any(w in item for w in _GENERIC_CHECK_WORDS) + and not any(ch.isdigit() for ch in item) + and not any(n and n in item for n in cargo_names) + ) + return [i for i in items if not generic(i)] + + +def _by_text(items: list[tuple[str, str]]) -> list[tuple[str, str]]: + """(우리 화물 이름, 문장) 을 문장별로 묶어 ('가솔린·케로젠', 문장) 으로 — 같은 사유를 화물마다 + 되풀이하지 않는다. 처음 나온 순서를 지킨다.""" + merged: dict[str, list[str]] = {} + for name, text in items: + names = merged.setdefault(text, []) + if name not in names: + names.append(name) + return [("·".join(names), text) for text, names in merged.items()] + + +_BERTH_NO = re.compile(r"^(.+?)-(\d+)선석$") + + +def _join_berths(berths: list[str]) -> str: + """['4부두-1선석', '4부두-2선석', 'SK6부두'] → '4부두 1·2선석, SK6부두' (처음 나온 순서).""" + groups: dict[str, list[str]] = {} + for b in dict.fromkeys(berths): + m = _BERTH_NO.match(b) + groups.setdefault(m.group(1) if m else b, []).append(m.group(2) if m else "") + return ", ".join( + f"{w} {'·'.join(sorted(nos, key=int))}선석" if all(nos) else w for w, nos in groups.items() + ) + + +def _verdict_basis( + verdicts: list[_Verdict], adjacent_count: int, onboard: list[OnboardConflict], +) -> list[str]: + """등급의 근거 — 화면이 등급 바로 아래 두는 줄들 (2026-09-29). + + _checked_facts 와 같은 사실을 한 줄씩 나눠, 등급을 만든 것부터 적는다. 검사를 못 한 + 것은 여기 넣지 않고 _needs_check 로 보낸다(근거와 '못 본 것'을 섞지 않는다). + + 화물을 여러 종 실은 배는 **모든 화물**의 근거를 모으고 어느 화물 얘기인지 이름을 붙인다. + 예전엔 대표 화물 것만 적어, 대표가 아닌 화물의 충돌은 화물별 등급 칩에만 보였다. + """ + multi = len(verdicts) > 1 + lines: list[str] = [] + if onboard: + pairs = sorted({f"{c.cargo_a_name}↔{c.cargo_b_name}" for c in onboard}) + lines.append(f"같은 선박 화물끼리 혼재 충돌 {len(pairs)}쌍({', '.join(pairs)}) — 격리 적재 확인 필요") + conflicts: list[tuple[str, str]] = [] + packaging: list[tuple[str, str]] = [] + for v in verdicts: + name = _cargo_display_name(v.target_row) + # [2026-09-29] 이웃 화물 하나당 한 줄 — 예전엔 (이웃 선석 × 기준)마다 한 줄이라, 4부두 1·2선석의 + # 질산 하나가 MSDS·46 CFR 150 으로 4줄이 됐다(사용자 지적: 배지 아래 가독성). 선석은 묶고 기준은 ' / '. + by_adj: dict[str, tuple[list[str], list[str]]] = {} + for c in v.conflicts: + berths, reasons = by_adj.setdefault(c.adjacent_name, ([], [])) + berths.append(c.adjacent_berth) + reasons.append(f"MSDS '{c.shared_category}' 혼재금지") + for c in v.bulk_compatibility_conflicts: + berths, reasons = by_adj.setdefault(c.adjacent_name, ([], [])) + berths.append(c.adjacent_berth) + reasons.append( + f"46 CFR 150 {c.target_group_name}({c.target_group}) ↔ {c.adjacent_group_name}({c.adjacent_group})" + ) + for adj_name, (berths, reasons) in by_adj.items(): + conflicts.append((name, f"{_join_berths(berths)} {adj_name} — {' / '.join(dict.fromkeys(reasons))}")) + for p in v.packaging_violations: + packaging.append((name, f"포장·하역방식 부적합: {p.reason}")) + for who, text in _by_text(conflicts): + lines.append(f"이웃 화물 충돌: {who} ↔ {text}" if multi else f"이웃 화물 충돌: {text}") + for who, text in _by_text(packaging): + lines.append(f"{who} · {text}" if multi else text) + # 판정 근거가 없는 이웃 화물도 등급을 올린다(근거 없는 조합을 안전으로 보지 않는다) — 등급의 + # 이유이므로 여기 적는다. 빠뜨리면 '주의'인데 근거 줄은 "충돌 없음"뿐이었다(실측 SH SOPHIA). + n_unassessed = len({p.adjacent_chem_id for v in verdicts for p in v.unassessed_pairs}) + if n_unassessed: + lines.append(f"이웃 화물 {n_unassessed}종은 혼재 판정 근거가 없음 — 근거가 없는 조합은 안전으로 보지 않습니다") + if adjacent_count == 0: + lines.append("이웃 선석에 실린 화물 없음 — 혼재 비교 대상 없음") + elif not conflicts: + lines.append( + "판정 근거가 있는 이웃 화물과는 MSDS·46 CFR 150 기준 충돌 없음" if n_unassessed + else f"{_adjacent_label(verdicts, adjacent_count)}과 MSDS·46 CFR 150 기준 충돌 없음" + ) + return lines + + +def _needs_check(verdicts: list[_Verdict]) -> list[str]: + """확인 필요 — 판정에 쓰지 못한 근거를 '대상 — 이유' 로 (2026-09-29). + + 판정 근거가 없는 이웃 화물은 한 줄로 묶는다. 사유가 대개 대상 화물 쪽 자료 공백이라 이웃마다 + 같은 문장이 되풀이됐다(실측: 30건 × 4구절). 모든 쌍에 공통인 사유만 앞 두 구절 남긴다. + 화물을 여러 종 실은 배는 모든 화물에서 모으고, 해당 화물 이름을 앞에 붙인다(_verdict_basis 와 같다). + """ + multi = len(verdicts) > 1 + + def tag(vs: list[_Verdict], text: str) -> str: + return f"{'·'.join(_cargo_display_name(v.target_row) for v in vs)} · {text}" if multi else text + + items: list[str] = [] + gap = [v for v in verdicts if v.unassessed_pairs] + if gap: + pairs = [p for v in gap for p in v.unassessed_pairs] + names = list(dict.fromkeys(p.adjacent_name for p in pairs)) + reasons = [set(p.reason.split(" / ")) for p in pairs] + common = [r for r in pairs[0].reason.split(" / ") if all(r in s for s in reasons)] + who = ", ".join(names[:3]) + (f" 외 {len(names) - 3}종" if len(names) > 3 else "") + items.append(tag(gap, f"이웃 화물 {who} — 혼재 판정 근거 부족" + + (f"({'; '.join(common[:2])})" if common else ""))) + # 하역방식은 용기등급 Ⅰ 화물만 판정한다(find_packing_violation). 그 밖의 화물은 알아도 등급이 + # 바뀌지 않는데, 하역방식을 넘기는 호출부가 없어 거의 모든 판정에 이 줄이 붙었다(2026-09-29). + unchecked = [v for v in verdicts + if v.target_row.packing_group == PACKING_GROUP_HIGH_RISK + and not v.unload_method_name and not v.packaging_violations] + if unchecked: + items.append(tag(unchecked, "하역방식 — 신고가 없어 포장·하역 적합성은 보지 못했습니다")) + return items async def assess_safety( @@ -545,23 +898,33 @@ async def assess_safety( ) -> SafetyAssessmentResult: """판정 + LLM 서술. 응답 형태는 종전과 같고 cargo_verdicts 만 늘었다. - LLM 서술은 대표(가장 위험한) 화물 하나에 대해서만 만든다 — 화물마다 부르면 - 호출 비용이 화물 수만큼 늘어난다. 나머지 화물의 등급은 cargo_verdicts 로 보인다. + LLM 은 한 번만 부른다 — 화물마다 부르면 호출 비용이 화물 수만큼 늘어난다. + [2026-09-29] 서술 대상은 대표 화물 하나가 아니라 _focus 다: 안전이 아닌 화물만, 모두 안전이면 + 전 화물을 묶어서. target_cargo_name 도 그 화물들 이름이다(화면의 "○○ 기준"). """ - v, summaries = await _compute_verdicts(db, neo4j_driver, request) + v, verdicts, summaries, onboard, ship_level = await _compute_verdicts(db, neo4j_driver, request) + needs_check = _needs_check(verdicts) + basis = _verdict_basis(verdicts, len(request.adjacent_cargos), onboard) + focus = _focus(verdicts, onboard) + methods = {x.unload_method_name for x in focus} llm_result = await llm_client.generate_structured( system_prompt=SYSTEM_PROMPT, user_prompt=build_user_prompt( - target_cargo_name=_cargo_display_name(v.target_row), - hazard_summary=v.hazard_summary, - conflicts=v.conflicts, - bulk_compatibility_conflicts=v.bulk_compatibility_conflicts, - packaging_violations=v.packaging_violations, - unassessed_pairs=v.unassessed_pairs, - rule_engine_floor=v.rule_engine_floor, + target_cargo_name=_focus_name(focus), + hazard_summary=_merged_hazards(focus), + conflicts=[c for x in focus for c in x.conflicts], + bulk_compatibility_conflicts=[c for x in focus for c in x.bulk_compatibility_conflicts], + packaging_violations=[p for x in focus for p in x.packaging_violations], + unassessed_pairs=[p for x in focus for p in x.unassessed_pairs], adjacent_count=len(request.adjacent_cargos), - unload_method_name=v.unload_method_name, + # 하나라도 신고가 없으면 '검사하지 않음'으로 넘긴다 — 본 것처럼 쓰지 않게. + unload_method_name="·".join(sorted(methods)) if None not in methods else None, + onboard_conflicts=onboard, + needs_check=needs_check, + # 모든 화물의 충돌을 화물 이름과 함께 넘긴다 — 대표 화물 것만 넘기면 나머지 화물의 + # 충돌이 체크리스트에서 빠졌다(2026-09-29). + conflict_checks=[b for b in basis if b.startswith("이웃 화물 충돌") or "포장·하역방식 부적합" in b], ), schema=LLMAssessment, ) @@ -578,15 +941,29 @@ async def assess_safety( # 자동배정에서 막히는 조합은 하나도 달라지지 않는다**(오케스트레이터는 # BLOCKED만 후보 탈락시킴) — 바뀌는 것은 화면에 표시되는 경고 등급뿐이다. return SafetyAssessmentResult( - target_cargo_name=_cargo_display_name(v.target_row), - risk_level=v.rule_engine_floor, + target_cargo_name=_focus_name(focus), + risk_level=ship_level, # 중합성 화물(IMDG ", STABILIZED")이면 탱크 온도·억제제 확인 항목을 규칙으로 # 맨 앞에 붙인다 — LLM 이 뽑을지에 맡기지 않는다(stabilized_cargo.py, 2026-09-17). checklist=with_stabilized_checklist( - llm_result.checklist, v.target_row.un_no, v.target_row.cas_no, + _drop_generic_checklist( + llm_result.checklist, [_cargo_display_name(x.target_row) for x in verdicts], + ), + v.target_row.un_no, v.target_row.cas_no, + ), + # [2026-09-29] 주요 위험성은 MSDS GHS 분류에서 코드가 만든다(_hazard_lines). 분류가 없는 화물뿐일 + # 때만 LLM 것을 쓴다. LLM 화물 설명(cargo_profile)은 쓰지 않는다 — 여러 화물이면 가장 센 물질의 + # 특성을 전부에 씌웠고(케로젠·디젤을 '고인화성'), 한 화물이어도 분류에 없는 위험을 썼다 + # (가솔린 '피부에 심각한 자극' — 피부 자극 분류 없음). 사용자 결정 9/29. + key_hazards=_hazard_lines(focus) or llm_result.key_hazards, + # reasoning 은 종전 소비처(챗봇 근거·옛 화면)용으로 남긴다 — 등급의 이유(코드) + 화물 특성(LLM). + reasoning="\n".join( + s for s in (_checked_facts(focus, len(request.adjacent_cargos), onboard, ship_level),) if s ), - key_hazards=llm_result.key_hazards, - reasoning=_checked_facts(v, len(request.adjacent_cargos)) + "\n" + llm_result.reasoning, + cargo_profile="", + adjacent_count=len(request.adjacent_cargos), + verdict_basis=basis, + needs_check=needs_check, conflicts=v.conflicts, # IMDG 항목은 **참고 정보**로 응답에 남긴다 — 판정 근거와 LLM 프롬프트 # 에서는 빠졌지만, 관제사가 "이 조합이 국제 규정상 선내 격리 대상인가"를 @@ -596,8 +973,9 @@ async def assess_safety( bulk_compatibility_conflicts=v.bulk_compatibility_conflicts, packaging_violations=v.packaging_violations, unassessed_pairs=v.unassessed_pairs, - rule_engine_floor=v.rule_engine_floor, + rule_engine_floor=ship_level, msds_sections_used=list(v.hazard_summary.keys()), imdg_classes=v.imdg_classes, cargo_verdicts=summaries, + onboard_conflicts=onboard, ) diff --git a/app/agents/tools.py b/app/agents/tools.py index cafdea2..d2d916b 100644 --- a/app/agents/tools.py +++ b/app/agents/tools.py @@ -60,6 +60,10 @@ class Opinion(BaseModel): evidence: list[str] = Field(default_factory=list, description="등급의 근거 문장") checked: list[str] = Field(default_factory=list, description="확인한 것") missing: list[str] = Field(default_factory=list, description="못 본 것") + notes: list[str] = Field( + default_factory=list, + description="[2026-09-29] 참고 — 판정에 쓰지 않은 값(외해 파고 등). '확인한 것' 칩에 두면 확인한 근거처럼 읽혔다", + ) class BerthCheck(BaseModel): @@ -135,14 +139,18 @@ async def check_segregation( cargo: CargoRef, cargos: list[CargoRef], adjacent_cargos: list[AdjacentCargo], + call_sign: str | None = None, ) -> SafetyAssessmentResult: - """이 배의 화물과 이웃 화물의 혼재 등급. 등급은 규칙 하한이 정하고 LLM 은 설명만 쓴다.""" + """이 배의 화물과 이웃 화물의 혼재 등급. 등급은 규칙 하한이 정하고 LLM 은 설명만 쓴다. + + call_sign 을 주면 이번 입항 건 화물 신고의 하역방식을 채운다(안전 에이전트가 조회, 2026-09-29). + """ return await assess_safety( db, neo4j_driver, llm_client, SafetyAssessmentRequest( - target_cargo=cargo, target_cargos=cargos, adjacent_cargos=adjacent_cargos, + target_cargo=cargo, target_cargos=cargos, adjacent_cargos=adjacent_cargos, call_sign=call_sign, ), ) @@ -207,22 +215,35 @@ def weather_opinion(w: WeatherAssessmentResult) -> Opinion: else "부적합" ) checked, missing = [], [] + # [2026-09-29] missing 은 화면의 '확인 필요' 줄이 된다 — '대상 — 이유'로 적는다. if w.wind.is_stale: - missing.append("풍속 관측 없음 또는 오래됨") + missing.append("풍속 — 관측이 끊겼거나 오래됐습니다") else: checked.append(f"풍속 관측 {w.wind.value}{w.wind.unit}") # 파고는 항내 부두에 적용하지 않는 값이라 없다고 '못 본 것'으로 적지 않는다. 적용되는 # 부두에서 없으면 등급이 이미 판정불가이고 사유가 reasons 에 남는다. - if not w.wave.is_stale: + # [2026-09-29] '적용하지 않음 - 참고값' 문장은 등급의 근거가 아니다 — 근거에서 빼고 확인한 것으로 + # 옮긴다. 예전엔 항내 부두 판정마다 근거 첫머리에 "파고 … 적용하지 않음"이 떴다(31건 중 30건). + evidence = [r for r in w.reasons if "적용하지 않음" not in r] + wave_reference = [r for r in w.reasons if "적용하지 않음" in r] + # 판정에 쓴 파고만 '파고 관측'으로 적는다. 안 쓴 파고까지 적으면 '확인' 칩에 "파고 관측 1.4m"와 + # "파고 1.4m … 적용하지 않음"이 나란히 떠, 파고를 봤다는 건지 안 봤다는 건지 읽히지 않았다. + if not w.wave.is_stale and not wave_reference: checked.append(f"파고 관측 {w.wave.value}{w.wave.unit}") - evidence = list(w.reasons) fw = w.forecast_warning if fw is not None: checked.append(f"체류 종료까지 예보 {fw.forecast_points_checked}개 시각") if fw.will_deteriorate and fw.earliest_deterioration_at_utc is not None: at = fw.earliest_deterioration_at_utc.astimezone(_KST) - evidence.append(f"{at:%m/%d %H:%M} 부터 '{fw.worst_status.value}' 예보") - return Opinion(axis="기상", level=level, evidence=evidence, checked=checked, missing=missing) + cause = f" ({' · '.join(fw.earliest_deterioration_causes)})" if fw.earliest_deterioration_causes else "" + evidence.append(f"{at:%m/%d %H:%M} 부터 '{fw.worst_status.value}' 예보{cause}") + elif level == "적합": + evidence.append("체류 중 악화 예보 없음") + if fw.no_forecast_after_utc is not None: + gap = fw.no_forecast_after_utc.astimezone(_KST) + missing.append(f"{gap:%m/%d %H:%M} 이후 기상 — 예보가 아직 나오지 않았습니다") + return Opinion(axis="기상", level=level, evidence=evidence, checked=checked, missing=missing, + notes=wave_reference) def segregation_opinion(s: SafetyAssessmentResult) -> Opinion: @@ -233,16 +254,14 @@ def segregation_opinion(s: SafetyAssessmentResult) -> Opinion: level = "부적합" else: level = "주의" - evidence = [f"[MSDS] {c.adjacent_name} — '{c.shared_category}'" for c in s.conflicts] - evidence += [f"[벌크] {c.adjacent_name} — {c.reason}" for c in s.bulk_compatibility_conflicts] - n_pairs = len({c.adjacent_chem_id for c in s.unassessed_pairs}) + # [2026-09-29] 근거는 안전 에이전트가 코드로 만든 verdict_basis 를 그대로 쓴다. 예전엔 이웃 충돌만 + # 옮겨, 같은 선박 화물 충돌로 '주의'가 된 배의 근거가 비어 있었다(실측 GOLDEN DENISE). return Opinion( axis="혼재", level=level, - evidence=evidence, - checked=[f"화물 {len(s.cargo_verdicts) or 1}종 × 이웃 화물, 규칙 하한 '{s.rule_engine_floor.value}'"], - missing=[f"{p.adjacent_name}: {p.reason}" for p in s.unassessed_pairs[:5]] - + ([f"외 {n_pairs - 5}종"] if n_pairs > 5 else []), + evidence=list(s.verdict_basis), + checked=[f"화물 {len(s.cargo_verdicts) or 1}종 × 이웃 화물 {s.adjacent_count}건, 규칙 하한 '{s.rule_engine_floor.value}'"], + missing=list(s.needs_check), ) diff --git a/app/agents/weather/schemas.py b/app/agents/weather/schemas.py index ab8c888..08a1c69 100644 --- a/app/agents/weather/schemas.py +++ b/app/agents/weather/schemas.py @@ -122,6 +122,17 @@ class ForecastWarning(BaseModel): earliest_deterioration_at_utc: datetime | None = Field( default=None, description="조건부가능 이상으로 처음 악화되는 예보 시각" ) + earliest_deterioration_causes: list[str] = Field( + default_factory=list, + description="[2026-09-29] 그 시각에 기준을 넘은 항목(예: '강수량 1.0mm/h >= 1.0mm/h'). 화면이 " + "'하역중단 예보'만 적어 무엇 때문인지 알 수 없었다.", + ) + no_forecast_after_utc: datetime | None = Field( + default=None, + description="[2026-09-29] 이 시각부터 체류 종료까지 예보 값이 없다. 악화가 아니라 '못 본 구간'이라 " + "will_deteriorate·worst_status 에 넣지 않는다 — 예전엔 값 없는 예보 시각이 '판단불가'로 " + "악화에 잡혀, 체류가 긴 배에 없는 악화 경고가 붙었다(실측 ATHENS SPIRIT, 5일 체류).", + ) points: list[ForecastPoint] diff --git a/app/agents/weather/service.py b/app/agents/weather/service.py index 20676f3..74a75bb 100644 --- a/app/agents/weather/service.py +++ b/app/agents/weather/service.py @@ -12,7 +12,7 @@ 비교" 요구사항 — 이 기능은 팀원 MVP에는 없고 기존 구현에만 있어 그대로 유지). """ -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from sqlalchemy.ext.asyncio import AsyncSession @@ -67,15 +67,21 @@ async def _build_forecast_warning( expected_completion_at: datetime, threshold, extra_condition_active: bool, + wave_applies: bool = True, ) -> ForecastWarning: + """wave_applies — 현재 관측 판정과 같은 값을 받는다(2026-09-29). 예보 파고도 해상 예보라 항내 부두에는 + 쓰지 않는다 — 예전엔 넘기지 않아, 풍속은 멀쩡한데 예보 파고 하나로 '하역중단 예보' 조건이 붙었다 + (실측: 우선호@달포부두 "09/29 13:00 부터 '하역중단' 예보").""" forecast_rows = await get_forecast_range(db, start=as_of, end=expected_completion_at) points: list[ForecastPoint] = [] worst_status = WorkStatus.NORMAL earliest_deterioration_at: datetime | None = None + earliest_causes: list[str] = [] + no_forecast_after: datetime | None = None for row in forecast_rows: - point_status, _ = evaluate( + point_status, point_reasons = evaluate( wind_speed_ms=row.wind_speed_ms, wind_is_stale=False, wave_height_m=row.wave_height_m, @@ -83,6 +89,7 @@ async def _build_forecast_warning( threshold=threshold, extra_condition_active=extra_condition_active, precip_mm=row.precip_mm, + wave_applies=wave_applies, ) points.append( ForecastPoint( @@ -93,10 +100,22 @@ async def _build_forecast_warning( precip_mm=row.precip_mm, ) ) + # 값이 없는 예보 시각(판단불가)은 악화가 아니라 예보 공백이다 — 따로 센다. + if point_status is WorkStatus.UNKNOWN: + if no_forecast_after is None: + no_forecast_after = row.fcst_at_utc + continue if severity(point_status) > severity(worst_status): worst_status = point_status if earliest_deterioration_at is None and point_status is not WorkStatus.NORMAL: earliest_deterioration_at = row.fcst_at_utc + # "강수량 1.0mm/h >= 1.0mm/h -> 하역중단" → "강수량 1.0mm/h >= 1.0mm/h" + earliest_causes = [r.split(" -> ")[0] for r in point_reasons if " -> " in r] + + # 예보 행 자체가 체류 종료 전에 끝나도 공백이다(예보 간격 3시간보다 길게 비면). + last_at = points[-1].fcst_at_utc if points else as_of + if no_forecast_after is None and expected_completion_at - last_at > timedelta(hours=3): + no_forecast_after = last_at return ForecastWarning( window_end_utc=expected_completion_at, @@ -104,6 +123,8 @@ async def _build_forecast_warning( will_deteriorate=earliest_deterioration_at is not None, worst_status=worst_status, earliest_deterioration_at_utc=earliest_deterioration_at, + earliest_deterioration_causes=earliest_causes, + no_forecast_after_utc=no_forecast_after, points=points, ) @@ -132,9 +153,11 @@ async def assess_weather(db: AsyncSession, request: WeatherAssessmentRequest) -> # [2026-09-21, D2 ②] 외해 부이 파고를 항내 부두에 대입하지 않는다. # wharf_name 을 안 넘긴 호출은 예전대로 적용한다(하위 호환) — 다만 그러면 # 항내 부두가 외해 파고로 막히므로, 판정 경로는 반드시 이 값을 채워야 한다. - wave_applies = ( - rule_engine_wave_applies_to(request.wharf_name) if request.wharf_name else True - ) + # [2026-09-29] 부두그룹만 넘긴 호출(대시보드 '부두 기상 판정')도 같은 규칙을 그룹 이름에 쓴다 — 예전엔 + # 이 경로가 하위 호환으로 적용돼, 에이전트는 외해 파고를 안 쓰는데 그 패널만 "파고 1.6m < 2.0m 정상"처럼 + # OTK·정일 등 항내 부두에 외해 파고를 대입했다. 부이 그룹(한국석유공사원유부이)은 그대로 적용된다. + wave_target = request.wharf_name or request.berth_group + wave_applies = rule_engine_wave_applies_to(wave_target) if wave_target else True status, reasons = evaluate( wind_speed_ms=wind_speed_ms, @@ -155,6 +178,7 @@ async def assess_weather(db: AsyncSession, request: WeatherAssessmentRequest) -> expected_completion_at=request.expected_completion_at, threshold=threshold, extra_condition_active=request.extra_condition_active, + wave_applies=wave_applies, ) return WeatherAssessmentResult( diff --git a/app/api/v1/approvals.py b/app/api/v1/approvals.py index 38b1c4b..46ba6b3 100644 --- a/app/api/v1/approvals.py +++ b/app/api/v1/approvals.py @@ -52,12 +52,26 @@ class PendingItem(BaseModel): assessed_at_utc: datetime +# [2026-09-29] 배마다 **최신 판정을 먼저 고른 뒤** 거른다. 예전엔 '적합 아님·미확인'을 먼저 걸러, +# 판정불가 → 적합으로 바뀐 배의 옛 판정불가가 그대로 남았다(실측: 확인 대기 판정불가 70척 중 31척). +# 판정은 바뀔 때만 기록되므로 시각만으로는 자를 수 없다 — 항내에 있는 배이거나(위치 관측) +# 24시간 안에 판정된 배만 '현재'로 본다. 떠난 배의 마지막 판정은 이력이다. +# berth_alerts._QUERY_ACTIVE_ASSESSMENTS 와 같은 조건이다(대시보드 타일과 경고 수가 같게). _QUERY_PENDING = text(""" + WITH latest AS ( + SELECT DISTINCT ON (call_sign) * + FROM assessment_history + ORDER BY call_sign, assessed_at_utc DESC + ), present AS ( + SELECT DISTINCT upper(btrim(callsgn)) AS cs FROM mart.vessel_presence + ) SELECT id, call_sign, vessel_name, stage, wharf_name, level, changed_from, action, recipient, reasons, assessed_at_utc - FROM assessment_history + FROM latest l WHERE acknowledged_at_utc IS NULL AND (NOT :only_actionable OR level <> :fit_level) + AND (assessed_at_utc > now() - interval '24 hours' + OR EXISTS (SELECT 1 FROM present p WHERE p.cs = upper(btrim(l.call_sign)))) ORDER BY assessed_at_utc DESC LIMIT :limit """) @@ -91,7 +105,11 @@ async def get_pending( }, ) ).mappings().all() - return [PendingItem(**dict(r)) for r in rows] + # 9/29 이전 판정 문장의 "배정된 선석" — 우리는 배정하지 않으므로 보일 때만 고친다(기록은 그대로). + return [ + PendingItem(**{**dict(r), "reasons": [s.replace("배정된 선석", "이 선석") for s in r["reasons"] or []]}) + for r in rows + ] class AcknowledgeRequest(BaseModel): diff --git a/app/api/v1/arrivals.py b/app/api/v1/arrivals.py index 7d31ad1..5ce5bc0 100644 --- a/app/api/v1/arrivals.py +++ b/app/api/v1/arrivals.py @@ -83,7 +83,13 @@ cg.cargos->0->>'chem_id' AS chem_id, cg.cargos->0->>'cas_no' AS cas_no, cg.cargos->0->>'name' AS cargo_name, (cg.cargos->0->>'is_synthetic')::boolean AS cargo_is_synthetic, a.collected_at_utc, - cg.cargos + cg.cargos, + -- [2026-09-29] 이 행의 입항 건 키와 판정 잡이 지금 보는 입항 건 키. 판정 기록은 같은 입항 건 것만 붙인다. + CASE WHEN a.entry_year IS NOT NULL AND a.entry_count IS NOT NULL + THEN upper(btrim(a.callsgn)) || '_' || a.entry_year::text || '_' || lpad(a.entry_count::text, 3, '0') + END AS port_call_key, + (SELECT vc.port_call_key FROM mart.vessel_current_call vc + WHERE vc.callsgn = upper(btrim(a.callsgn))) AS current_call_key FROM arr a LEFT JOIN fac f ON f.source_name = a.arrival_facility_nm LEFT JOIN berth b ON b.wharf_name = f.wharf_name @@ -108,15 +114,29 @@ _QUERY_HAS_HISTORY = text("SELECT to_regclass('public.assessment_history') IS NOT NULL") +# [2026-09-29] 입항 건별 최신 판정. 예전엔 호출부호의 최신 판정 하나를 붙여, 입항이 12시간 넘게 남은 +# 배의 새 입항 건 옆에 지난 입항 건 판정이 떴다(실측: 기록이 있는 7척 전부 화물이 달랐다). +# 입항 건 키가 없는 옛 기록(port_call_key '')은 이 행이 판정 잡의 지금 입항 건일 때만 붙인다. _QUERY_LATEST_ASSESSMENT = text(""" - SELECT DISTINCT ON (call_sign) call_sign, stage, level, reasons, action, recipient, - changed_from, assessed_at_utc + SELECT DISTINCT ON (upper(btrim(call_sign)), COALESCE(input_snapshot->>'port_call_key', '')) + upper(btrim(call_sign)) AS cs, COALESCE(input_snapshot->>'port_call_key', '') AS port_call_key, + call_sign, stage, level, reasons, action, recipient, changed_from, assessed_at_utc FROM assessment_history WHERE upper(btrim(call_sign)) = ANY(CAST(:call_signs AS text[])) - ORDER BY call_sign, assessed_at_utc DESC + ORDER BY upper(btrim(call_sign)), COALESCE(input_snapshot->>'port_call_key', ''), assessed_at_utc DESC """) +def _assessment_for(row: dict, by_key: dict[tuple[str, str], dict]) -> dict | None: + """이 행(입항 건)의 판정. 같은 입항 건 기록이 없으면 키 없는 옛 기록을, 지금 입항 건일 때만.""" + cs = (row.get("call_sign") or "").strip().upper() + key = row.get("port_call_key") or "" + hit = by_key.get((cs, key)) if key else None + if hit is None and (not key or key == row.get("current_call_key")): + hit = by_key.get((cs, "")) + return hit + + def _stage(row: dict, now: datetime) -> str: nav = row.get("nav_status_code") or "" if nav.startswith("정박(계류)"): @@ -144,12 +164,13 @@ async def get_upcoming_arrivals( _QUERY_UPCOMING, {"ahead_hours": ahead_hours, "past_hours": past_hours}, )).mappings().all()] - assessments: dict[str, dict] = {} + assessments: dict[tuple[str, str], dict] = {} has_history = bool((await db.execute(_QUERY_HAS_HISTORY)).scalar()) if rows and has_history: call_signs = sorted({r["call_sign"].strip().upper() for r in rows if r["call_sign"]}) for a in (await db.execute(_QUERY_LATEST_ASSESSMENT, {"call_signs": call_signs})).mappings().all(): - assessments[a["call_sign"].strip().upper()] = dict(a) + a = dict(a) + assessments[(a.pop("cs"), a.pop("port_call_key"))] = a now = datetime.now(timezone.utc) items = [] @@ -157,7 +178,7 @@ async def get_upcoming_arrivals( depth, draught = r.get("depth_m"), r.get("draught_m") r["chart_margin_m"] = round(float(depth) - float(draught), 2) if depth is not None and draught is not None else None r["stage"] = _stage(r, now) - r["assessment"] = assessments.get((r["call_sign"] or "").strip().upper()) + r["assessment"] = _assessment_for(r, assessments) items.append(r) return { diff --git a/app/api/v1/orchestrator.py b/app/api/v1/orchestrator.py index 56810b5..042f4c5 100644 --- a/app/api/v1/orchestrator.py +++ b/app/api/v1/orchestrator.py @@ -49,9 +49,14 @@ 배정은 항만공사 선석회의가 하고, 우리는 그 배정이 조건에 맞는지 확인합니다. 다른 자리가 필요하면 `POST /scheduling/alternatives`(대체 선석 제안)를 쓰세요. -**`overall_decision`** 은 아직 배정 주체의 어휘(`승인가능`/`적합선석없음` …)로 나옵니다. -화면에 그대로 쓰지 마세요 — `assessment_history.level`(적합/주의/부적합/판정불가)로 -옮겨진 값을 쓰거나, `/orchestrator/assess-and-record` 를 부르면 그 변환까지 끝납니다. +**`overall_decision`** 은 아직 배정 주체의 어휘(`적합`/`적합선석없음` …)로 나옵니다. +화면에 그대로 쓰지 마세요 — **`level`**(적합/주의/부적합/판정불가)을 쓰세요. 판정 기록 +(`assessment_history.level`)과 같은 함수로 정한 값입니다. `headline` 은 그 등급의 이유, +`needs_check` 는 판정에 쓰지 못한 근거(확인 필요)입니다. + +`target_source`(AIS/PORT-MIS)를 주면 판정 문구가 "지금 접안한 선석"/"배정된 선석"으로 +갈립니다. `vessel.call_sign` 을 주면 PORT-MIS 신고 계류시설을 읽어 실제 접안 부두와 다를 때 +판정 경로에 한 줄 남깁니다. `assignment_trace`는 판단 경로를 사람이 읽는 문장으로 담습니다. @@ -68,6 +73,12 @@ async def _orchestrate_or_http( db: AsyncSession, llm_client: LLMClient, request: OrchestratorRequest, ) -> OrchestratorResult: + # [2026-09-29] 실제 접안 부두로 판정할 때 PORT-MIS 신고 계류시설을 함께 넘긴다 — 둘이 다르면 + # 판정 경로에 신고값이 한 줄 남는다. 콘솔은 신고값을 모르므로 호출부호로 여기서 읽는다. + if request.reported_wharf_name is None and request.vessel.call_sign: + live = (await db.execute(_QUERY_LIVE_STATE, {"call_sign": request.vessel.call_sign})).mappings().first() + if live and live["facility_name"]: + request = request.model_copy(update={"reported_wharf_name": live["facility_name"]}) try: return await orchestrate(db, neo4j_client.driver, llm_client, request) except MsdsNotFoundError as e: @@ -107,6 +118,11 @@ async def assess( class AssessAndRecordRequest(OrchestratorRequest): call_sign: str = Field(description="선박 호출부호 — 판정 행의 식별 키") vessel_name: str | None = None + port_call_key: str | None = Field( + default=None, + description="[2026-09-29] 어느 입항 건의 판정인가(예: 'DSPC6_2026_011'). 입항 판정 화면의 행이 넘긴다. " + "비우면 mart.vessel_current_call 의 지금 입항 건. 화면은 같은 입항 건의 기록만 그 행에 붙인다.", + ) # 시점(stage)은 AIS 항해상태로만 정한다 — 프런트가 보낸 값을 믿지 않고 여기서 읽는다. @@ -130,6 +146,10 @@ class AssessAndRecordRequest(OrchestratorRequest): LIMIT 1 """) +_QUERY_CURRENT_CALL_KEY = text( + "SELECT port_call_key FROM mart.vessel_current_call WHERE callsgn = upper(btrim(:call_sign))" +) + class AssessAndRecordResult(BaseModel): result: OrchestratorResult @@ -157,6 +177,9 @@ async def assess_and_record( live = (await db.execute(_QUERY_LIVE_STATE, {"call_sign": request.call_sign})).mappings().first() stage = stage_from_nav_status(live["nav_status_code"] if live else None) + port_call_key = request.port_call_key or ( + await db.execute(_QUERY_CURRENT_CALL_KEY, {"call_sign": request.call_sign}) + ).scalar() recorded = await record_from_orchestrator( db, @@ -176,6 +199,7 @@ async def assess_and_record( ), "draught_m": float(request.vessel.draught_m) if request.vessel.draught_m else None, "requested_at_utc": datetime.now(timezone.utc).isoformat(), + "port_call_key": port_call_key, }, ) await db.commit() diff --git a/app/gate/bridge.py b/app/gate/bridge.py index 07c8200..bcbafcb 100644 --- a/app/gate/bridge.py +++ b/app/gate/bridge.py @@ -44,7 +44,7 @@ from sqlalchemy import text from app.agents.weather.data_access import get_berth_threshold -from app.agents.weather.rule_engine import evaluate +from app.agents.weather.rule_engine import evaluate, wave_applies_to from app.agents.weather.schemas import WorkStatus from app.config import get_settings from app.database import AsyncSessionFactory @@ -279,9 +279,14 @@ async def evaluate_once(self) -> None: wave = demo["wave_m"] if demo.get("wave_m") is not None else wx.get("wave_height_sig_m") wind_stale = demo.get("wind_ms") is None and (wind_at is None or now - wind_at > _OBS_MAX_AGE) wave_stale = demo.get("wave_m") is None and (wave_at is None or now - wave_at > _OBS_MAX_AGE) + # 실측 파고는 외해 부이 값이라 기상 에이전트와 같은 규칙(wave_applies_to)으로만 쓴다 — + # 예전엔 기본값(적용)으로 불러, 에이전트는 '적합'인데 정일1부두 게이트가 파고 1.4m 로 + # 잠겼다(2026-09-29 실측). 시연 파고는 관제사가 그 부두 파고로 넣은 값이라 적용한다. + wave_applies = demo.get("wave_m") is not None or wave_applies_to(berth) status, reasons = evaluate( wind_speed_ms=wind, wind_is_stale=wind_stale, wave_height_m=wave, wave_is_stale=wave_stale, threshold=th, + wave_applies=wave_applies, ) weather_lock = status is not WorkStatus.NORMAL dv = demo_verdict.get(gate_id) @@ -295,7 +300,7 @@ async def evaluate_once(self) -> None: state = "LOCKED" if (weather_lock or blocking) else "UNLOCKED" reason, reason_ko = "", [] if weather_lock: - reason = _short_reason(status, wind, wave, th) + reason = _short_reason(status, wind, wave if wave_applies else None, th) # 화면 근거는 넘긴 항목만 — "파고 0.5m < 2.0m 정상" 같은 줄까지 붙이면 관제사가 # 무엇 때문에 잠겼는지 한눈에 못 본다. 넘긴 항목이 없으면(판단불가) 전부 보인다. exceeded = [r for r in reasons if "->" in r] diff --git a/app/jobs/arrival_watcher.py b/app/jobs/arrival_watcher.py index def827a..dbbbba2 100644 --- a/app/jobs/arrival_watcher.py +++ b/app/jobs/arrival_watcher.py @@ -133,6 +133,9 @@ WHERE vc.callsgn = upper(btrim(dc.callsgn)) AND cm.chem_id IS NOT NULL ORDER BY cm.chem_id, cm.bl_no ) AS chem_ids, + -- [2026-09-29] 화물을 가져온 입항 건 키 — 판정 기록에 남겨 화면이 같은 입항 건의 판정만 붙인다. + (SELECT vc.port_call_key FROM mart.vessel_current_call vc + WHERE vc.callsgn = upper(btrim(dc.callsgn))) AS port_call_key, -- 판정할 선석(_target_wharf 와 같은 순서: 실제 접안 부두 → PORT-MIS 신고)의 재항 중앙값 ( SELECT bds.median_hours @@ -219,6 +222,7 @@ def _iso(value) -> str | None: "draught_m": float(row["draught_m"]) if row.get("draught_m") is not None else None, "arrival_at_utc": _iso(row.get("arrival_at_utc")), "chem_ids": list(row.get("chem_ids") or []), + "port_call_key": row.get("port_call_key"), } @@ -230,6 +234,7 @@ async def judge_with_llm_on_change( *, call_sign: str, stage: AssessmentStage | None, + port_call_key: str = "", ) -> OrchestratorResult | None: """판정하되 LLM 은 결과가 바뀔 때만 부른다. 바뀌지 않았으면 None. @@ -240,9 +245,12 @@ async def judge_with_llm_on_change( 규칙 판정은 한 척 1초 안팎이라 두 번 돌아도 부담이 작다. """ result = await orchestrate(db, neo4j_driver, NullLLMClient(), request) - if await is_unchanged_from_last(db, call_sign=call_sign, stage=stage, result=result): + if await is_unchanged_from_last(db, call_sign=call_sign, stage=stage, result=result, + port_call_key=port_call_key): return None - return await orchestrate(db, neo4j_driver, llm_client, request) + # [2026-09-29] 종합 문장(summary)은 판정 기록에 저장되지 않는다 — 잡에서는 LLM 으로 쓰지 않는다. + # 안전 서술(key_hazards 는 axes 에 저장)만 실제 LLM 으로 받는다. + return await orchestrate(db, neo4j_driver, llm_client, request, summarize=False) async def watch_arrivals() -> None: @@ -326,6 +334,8 @@ async def watch_arrivals() -> None: # 검증모드 고정 — 이 시설 하나만 확인한다. None 을 넘기면 오케스트레이터가 # 탐색모드로 떨어져 선석 top-3 를 새로 고른다. 그건 배정이다. assigned_wharf_name=target, + target_source=source, + reported_wharf_name=row.get("assigned_facility_name"), ) logger.info( "watch_arrivals: %s 검증 (%s / 출처 %s / 시점 %s)", @@ -336,6 +346,7 @@ async def watch_arrivals() -> None: try: result = await judge_with_llm_on_change( db, neo4j_client.driver, llm_client, request, call_sign=callsgn, stage=stage, + port_call_key=row.get("port_call_key") or "", ) except Exception: logger.exception("watch_arrivals: %s 오케스트레이터 호출 실패", callsgn) diff --git a/app/main.py b/app/main.py index 0ac1401..796a18f 100644 --- a/app/main.py +++ b/app/main.py @@ -100,7 +100,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: {"name": "weather", "description": "기상 기반 하역 가능 여부 판정. LLM 미사용."}, {"name": "safety", "description": "화물 혼재 위험 판정."}, {"name": "dashboard", "description": "모니터링 현황 조회. 판정 로직 없음."}, - {"name": "approvals", "description": "관제사 사전승인. 시스템 자동추천(REQUESTED)을 승인/반려해야 실제 배정이 확정된다."}, + {"name": "approvals", "description": "관제사 사전승인. 시스템이 검증해 올린 선석(REQUESTED)을 승인/반려해야 실제 배정이 확정된다."}, {"name": "chatbot", "description": "챗봇 화면용 목록 조회. 질의응답은 `rag`."}, {"name": "msds", "description": "MSDS 원문 조회."}, {"name": "health", "description": "서비스 상태 확인."}, diff --git a/app/services/assessment.py b/app/services/assessment.py index 1f6bd0f..a619afb 100644 --- a/app/services/assessment.py +++ b/app/services/assessment.py @@ -18,7 +18,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.agents.orchestrator.schemas import OrchestratorResult, OverallDecision -from app.agents.safety.schemas import RiskLevel +from app.agents.safety.schemas import RiskLevel, risk_level_rank from app.agents.weather.schemas import WorkStatus from app.models.assessment_history import ( AssessmentAction, @@ -68,9 +68,20 @@ def stage_from_nav_status(nav_status_code: str | None) -> AssessmentStage | None } +_SUBJECT_BY_SOURCE = {"AIS": "지금 접안한 선석", "PORT-MIS": "배정된 선석"} + + def level_from_decision(result: OrchestratorResult) -> tuple[AssessmentLevel, str]: - """오케스트레이터 귀결 → 판정 등급. 두 번째 값은 사람이 읽는 한 줄.""" + """오케스트레이터 귀결 → 판정 등급. 두 번째 값은 사람이 읽는 한 줄. + + [2026-09-29] 주어를 "배정된 선석"에서 출처대로 바꿨다. 판정 대상은 대개 AIS 로 본 실제 + 접안 부두인데 PORT-MIS 는 아직 정박지로 적고 있어(실측 27척 중 26척), "배정된"은 사실과 + 달랐다. 등급 비교(_same_as_last)는 문구를 보지 않으므로 이 변경으로 기록 행이 새로 생기지 않는다. + 같은 날 PORT-MIS 출처는 "배정된 선석"으로 다시 바꿨다 — 아직 붙지 않은 배의 사전배정 자리이고, + 배정(PORT-MIS)과 접안(AIS)은 다른 사실이다. 정박지 신고는 판정 대상이 아니라 여기 오지 않는다. + """ decision = result.overall_decision + subject = _SUBJECT_BY_SOURCE.get(result.target_source, "이 선석") if decision is OverallDecision.APPROVED: # 기상이 '정상'이 아니면 통과라도 주의다 — 하역중단 임계를 넘지 않았을 뿐 @@ -79,21 +90,31 @@ def level_from_decision(result: OrchestratorResult) -> tuple[AssessmentLevel, st if weather.status is WorkStatus.UNKNOWN: return AssessmentLevel.UNKNOWN, "기상 관측이 없거나 오래돼 판단할 수 없습니다" if weather.status is not WorkStatus.NORMAL: - return AssessmentLevel.CAUTION, f"배정된 선석은 조건에 맞으나 기상이 '{weather.status.value}'입니다" + return AssessmentLevel.CAUTION, f"{subject}은 조건에 맞으나 기상이 '{weather.status.value}'입니다" # [2026-09-26] 오케스트레이터는 혼재 '배정불가'만 탈락시키므로 여기 온 결과의 # 혼재 등급은 안전·주의·위험 중 하나다. 예전엔 이 등급을 보지 않아 위험이어도 # '적합'으로 기록됐다(실측: 적합인데 혼재 주의 2건). 위험·주의는 '주의'로 둔다 — # 배정불가만 막고 나머지는 경고로 보인다는 safety/service.py 의 설계를 따른다. safety = result.safety_assessment if safety is not None and safety.risk_level in (RiskLevel.DANGER, RiskLevel.CAUTION): + # [2026-09-28] 같은 선박 화물끼리 충돌해도 '주의'가 된다 — 어느 쪽인지 문장에 적는다. + adjacent_level = max( + (v.risk_level for v in safety.cargo_verdicts), + key=risk_level_rank, default=safety.risk_level, + ) + if safety.onboard_conflicts and risk_level_rank(adjacent_level) < risk_level_rank(safety.risk_level): + return ( + AssessmentLevel.CAUTION, + f"{subject}은 조건에 맞으나 같은 선박 화물끼리 혼재 충돌이 있습니다 — 격리 적재 확인 필요", + ) return ( AssessmentLevel.CAUTION, - f"배정된 선석은 조건에 맞으나 인접 화물 혼재 등급이 '{safety.risk_level.value}'입니다", + f"{subject}은 조건에 맞으나 인접 화물 혼재 등급이 '{safety.risk_level.value}'입니다", ) if result.conditions: # 교차 확인 조건(체류 중 기상 악화 예보 등). 등급은 적합 그대로다(결정 7-1). - return AssessmentLevel.FIT, "배정된 선석이 이 선박·화물 조건에 맞습니다(조건부 — 체류 중 악화 예보)" - return AssessmentLevel.FIT, "배정된 선석이 이 선박·화물 조건에 맞습니다" + return AssessmentLevel.FIT, f"{subject}이 이 선박·화물 조건에 맞습니다(조건부 — 체류 중 악화 예보)" + return AssessmentLevel.FIT, f"{subject}이 이 선박·화물 조건에 맞습니다" if decision is OverallDecision.WEATHER_BLOCKED: if result.evidence_missing: @@ -118,12 +139,12 @@ def level_from_decision(result: OrchestratorResult) -> tuple[AssessmentLevel, st detail = result.assignment_trace[0] if result.assignment_trace else None if result.evidence_missing: return AssessmentLevel.UNKNOWN, detail or "판정에 필요한 근거가 없습니다" - return AssessmentLevel.UNFIT, detail or "배정된 선석이 이 선박 조건에 맞지 않습니다" + return AssessmentLevel.UNFIT, detail or f"{subject}이 이 선박 조건에 맞지 않습니다" if decision is OverallDecision.ALL_CANDIDATES_UNSAFE: # 검증모드에서 이 귀결은 "다른 선석이 없다"가 아니라 **"배정된 그 선석이 # 이 배에 안 맞는다"** 는 뜻이다. 대상이 한 곳뿐이기 때문이다. - return AssessmentLevel.UNFIT, "배정된 선석이 이 선박·화물 조건에 맞지 않습니다" + return AssessmentLevel.UNFIT, f"이웃 화물과 혼재 충돌이 있어 {subject}이 이 화물에 맞지 않습니다" return AssessmentLevel.UNKNOWN, f"판정 결과를 해석할 수 없습니다({decision.value})" @@ -168,6 +189,8 @@ def _axes_from_result(result: OrchestratorResult) -> dict: } for v in s.cargo_verdicts ], + # [2026-09-28] 같은 선박 화물끼리의 혼재 충돌(격리 적재 확인 필요). + "onboard": [c.model_dump() for c in s.onboard_conflicts], } # [2026-09-27] 도구별 의견(등급 · 확인한 것 · 못 본 것)과 교차 확인 조건. condition_key 는 @@ -228,6 +251,10 @@ def _reasons_from_result(result: OrchestratorResult, headline: str) -> list[str] for v in verdicts ) reasons.append(f"화물 {len(verdicts)}종 혼재 판정: {parts}") + onboard = result.safety_assessment.onboard_conflicts if result.safety_assessment else [] + if onboard: + pairs = ", ".join(sorted({f"{c.cargo_a_name}↔{c.cargo_b_name}" for c in onboard})) + reasons.append(f"같은 선박 화물 혼재 충돌: {pairs} — 격리 적재 확인 필요") if result.suggested_alternatives: names = ", ".join( f"{c.wharf_name}(여유 {c.draught_margin_m:.1f}m)" @@ -236,6 +263,8 @@ def _reasons_from_result(result: OrchestratorResult, headline: str) -> list[str] reasons.append(f"대체 선석 제안: {names} — 제안이며 배정이 아닙니다") elif result.suggestion_note: reasons.append(f"대체 선석을 제안하지 못했습니다: {result.suggestion_note}") + # [2026-09-29] 판정에 쓰지 못한 근거 — 맨 뒤에 둔다(앞 줄을 읽는 화면·챗봇의 순서를 바꾸지 않게). + reasons.extend(f"확인 필요: {m}" for m in result.needs_check) # 중복 제거하되 순서는 유지한다(관제사가 읽는 순서가 근거의 우선순위다). seen: set[str] = set() return [r for r in reasons if r and not (r in seen or seen.add(r))] @@ -250,7 +279,9 @@ def _reasons_from_result(result: OrchestratorResult, headline: str) -> list[str] '' ) AS suggested_key, -- 교차 확인 조건(예보 등급 등). 시각은 넣지 않은 요약이다. - COALESCE(axes->>'condition_key', '') AS condition_key + COALESCE(axes->>'condition_key', '') AS condition_key, + -- [2026-09-29] 어느 입항 건의 판정인가. 입항 건이 바뀌면 등급이 같아도 새로 기록한다. + COALESCE(input_snapshot->>'port_call_key', '') AS port_call_key FROM assessment_history WHERE call_sign = :call_sign ORDER BY assessed_at_utc DESC @@ -259,11 +290,18 @@ def _reasons_from_result(result: OrchestratorResult, headline: str) -> list[str] def _same_as_last(last, stage_value: str, level: AssessmentLevel, - action_value: str | None, suggested_key: str, condition_key: str = "") -> bool: - """직전 기록과 시점·등급·조치안·제안·조건이 모두 같은가 — 같으면 새로 기록하지 않는다.""" + action_value: str | None, suggested_key: str, condition_key: str = "", + port_call_key: str = "") -> bool: + """직전 기록과 시점·등급·조치안·제안·조건·입항 건이 모두 같은가 — 같으면 새로 기록하지 않는다. + + [2026-09-29] 입항 건(port_call_key)을 더했다. 새 입항 건인데 등급이 같으면 기록이 생기지 않아, + 화면이 새 입항 건 옆에 지난 입항 건 판정을 붙여 보였다(실측 12h 밖 기록 7척 전부 화물이 달랐다). + 키를 모르는 호출(빈 값)은 입항 건을 비교하지 않는다. + """ return last is not None and last["stage"] == stage_value and last["level"] == level.value \ and last["action"] == action_value and last["suggested_key"] == suggested_key \ - and last["condition_key"] == condition_key + and last["condition_key"] == condition_key \ + and (not port_call_key or last["port_call_key"] == port_call_key) async def record_assessment( @@ -300,7 +338,8 @@ async def record_assessment( action_value = action.value if action is not None else None last = (await db.execute(_QUERY_LAST, {"call_sign": call_sign})).mappings().first() - if _same_as_last(last, stage_value, level, action_value, suggested_key, condition_key): + if _same_as_last(last, stage_value, level, action_value, suggested_key, condition_key, + (input_snapshot or {}).get("port_call_key") or ""): return False recipient = _RECIPIENT_BY_STAGE.get(stage) if (stage is not None and action is not None) else None @@ -345,6 +384,7 @@ async def is_unchanged_from_last( call_sign: str, stage: AssessmentStage | None, result: OrchestratorResult, + port_call_key: str = "", ) -> bool: """이 결과를 record_from_orchestrator 에 넘기면 '변화 없음'으로 기록이 생략되는가. @@ -356,7 +396,7 @@ async def is_unchanged_from_last( stage_value = stage.value if stage is not None else AssessmentStage.BEFORE_ARRIVAL.value last = (await db.execute(_QUERY_LAST, {"call_sign": call_sign})).mappings().first() return _same_as_last(last, stage_value, level, action.value if action is not None else None, - _suggested_key(result), result.condition_key) + _suggested_key(result), result.condition_key, port_call_key) async def record_from_orchestrator( diff --git a/tests/test_agent_fixes_20260926.py b/tests/test_agent_fixes_20260926.py index 219abab..9945615 100644 --- a/tests/test_agent_fixes_20260926.py +++ b/tests/test_agent_fixes_20260926.py @@ -86,7 +86,8 @@ async def fake_alternatives(*a, **k): monkeypatch.setattr(orch.tools, "check_weather", fake_weather) monkeypatch.setattr(orch.tools, "suggest_alternatives", fake_alternatives) - result = await orch.orchestrate(None, None, _NoLLM(), _verify_request()) + # 판정 경로만 본다 — 종합 문장 LLM 은 끈다(2026-09-29 부터 부적합에도 종합 문장을 쓴다). + result = await orch.orchestrate(None, None, _NoLLM(), _verify_request(), summarize=False) level, headline = level_from_decision(result) assert result.overall_decision is OverallDecision.WEATHER_BLOCKED assert level is AssessmentLevel.UNFIT diff --git a/tests/test_controller_review_20260929.py b/tests/test_controller_review_20260929.py new file mode 100644 index 0000000..bcfa56d --- /dev/null +++ b/tests/test_controller_review_20260929.py @@ -0,0 +1,67 @@ +"""관제사 관점 점검 (2026-09-29) — 경고 문구가 안전 에이전트 판정과 어긋나던 곳.""" +import pytest + +from app.agents.safety import berth_alerts +from app.agents.chatbot.schemas import Confidence +from app.agents.chatbot.service import _operational_confidence +from app.agents.safety.service import _drop_generic_checklist + + +@pytest.mark.parametrize("found_any, has_assessment, insufficient, expected", [ + (False, True, True, Confidence.MEDIUM), # 9/29 실측 — 가상의 배 + 규칙 판정: '낮음'이던 것 + (False, True, False, Confidence.HIGH), + (True, False, False, Confidence.HIGH), + (True, False, True, Confidence.LOW), + (False, False, False, Confidence.LOW), +]) +def test_operational_confidence_respects_rule_verdict(found_any, has_assessment, insufficient, expected): + assert _operational_confidence( + found_any=found_any, has_assessment=has_assessment, data_insufficient=insufficient, + ) is expected + + +def test_generic_checklist_items_are_dropped(): + # 9/29 실측 가솔린 체크리스트 — 프롬프트로 금지해도 일반 수칙이 절반이었다 + items = [ + "환기 상태를 점검하여 증기 농도가 위험 수치에 이르지 않도록 하시오.", + "탱크와 연결 호스의 접지가 제대로 이루어졌는지 확인하시오.", + "작업 중 금연 및 열점화원 제거 상태를 확인하시오.", + "인화점 -40℃ 이므로 하역 중 점화원 관리를 확인하시오.", # 숫자가 있으면 남긴다 + "가솔린·케로젠 ↔ 옆 부두 질산 혼재금지 — 격리 거리를 확인하시오.", # 화물 이름이 있으면 남긴다 + ] + assert _drop_generic_checklist(items, ["가솔린", "케로젠"]) == [items[1], items[3], items[4]] + +_A, _B = "000001", "000002" + + +def _rows(ship_of_b: str) -> list[dict]: + return [ + {"facility_name": "UTT부두", "callsgn": "SHIP1", "chem_id": _A, "cargo_name": "1,2-디아미노에탄", + "imdg_class": None, "wharf_name": None}, + {"facility_name": "UTT부두", "callsgn": ship_of_b, "chem_id": _B, "cargo_name": "2-에틸헥실 아크릴산", + "imdg_class": None, "wharf_name": None}, + ] + + +@pytest.fixture +def msds_conflict(monkeypatch): + # A ↔ B MSDS 혼재금지 1건 — 그래프 조회만 바꿔 끼운다(판정 함수는 그대로) + async def fake_facts(driver, sem, target_id, other_ids): + other = _B if target_id == _A else _A + return ([{"chem_id": other, "category": "가연성물질"}], [], set(), [], {}, (set(), set())) + + monkeypatch.setattr(berth_alerts, "_segregation_facts", fake_facts) + + +@pytest.mark.asyncio +async def test_same_ship_pair_is_caution_isolation_check(msds_conflict): + # GOLDEN DENISE 재현 — 한 배가 실은 두 화물이 '→ 배정불가'로 올라, 판정 '주의'와 나란히 떴다 + [alert] = await berth_alerts._segregation_alerts(_rows("SHIP1"), None) + assert alert["risk_level"] == "주의" and alert["level"] == "WARNING" + assert "같은 선박 적재 — 격리 적재 확인 필요" in alert["message"] + + +@pytest.mark.asyncio +async def test_two_ships_pair_keeps_rule_engine_level(msds_conflict): + [alert] = await berth_alerts._segregation_alerts(_rows("SHIP2"), None) + assert alert["risk_level"] != "주의" and "같은 선박" not in alert["message"] diff --git a/tests/test_llm_skip_20260926.py b/tests/test_llm_skip_20260926.py index a734b92..054ed4e 100644 --- a/tests/test_llm_skip_20260926.py +++ b/tests/test_llm_skip_20260926.py @@ -87,7 +87,7 @@ async def fake_orchestrate(_db, _driver, llm_client, _request, evidence_missing) # 결과가 같다 → 규칙 판정 한 번, LLM 없음, None monkeypatch.setattr(arrival_watcher, "orchestrate", - lambda d, dr, l, r: fake_orchestrate(d, dr, l, r, True)) + lambda d, dr, l, r, **kw: fake_orchestrate(d, dr, l, r, True)) got = await arrival_watcher.judge_with_llm_on_change( db, None, real_llm, None, call_sign=CALL_SIGN, stage=stage) assert got is None @@ -96,7 +96,7 @@ async def fake_orchestrate(_db, _driver, llm_client, _request, evidence_missing) # 결과가 바뀌었다 → 규칙 판정 뒤 실제 LLM 으로 다시 used.clear() monkeypatch.setattr(arrival_watcher, "orchestrate", - lambda d, dr, l, r: fake_orchestrate(d, dr, l, r, False)) + lambda d, dr, l, r, **kw: fake_orchestrate(d, dr, l, r, False)) got = await arrival_watcher.judge_with_llm_on_change( db, None, real_llm, None, call_sign=CALL_SIGN, stage=stage) assert got is not None and got.evidence_missing is False diff --git a/tests/test_needs_check_20260927.py b/tests/test_needs_check_20260927.py index c877f23..26f1802 100644 --- a/tests/test_needs_check_20260927.py +++ b/tests/test_needs_check_20260927.py @@ -20,7 +20,7 @@ def _result(conditions=(), **safety): base = dict( risk_level=RiskLevel.CAUTION, rule_engine_floor=RiskLevel.CAUTION, unassessed_pairs=[_pair("쌍1"), _pair("쌍2")], conflicts=[], bulk_compatibility_conflicts=[], - packaging_violations=[], cargo_verdicts=[], + packaging_violations=[], cargo_verdicts=[], onboard_conflicts=[], ) base.update(safety) return SimpleNamespace( @@ -28,6 +28,7 @@ def _result(conditions=(), **safety): weather_assessment=SimpleNamespace(status=WorkStatus.NORMAL), safety_assessment=SimpleNamespace(**base), conditions=list(conditions), + target_source=None, ) diff --git a/tests/test_port_call_key_20260929.py b/tests/test_port_call_key_20260929.py new file mode 100644 index 0000000..f054207 --- /dev/null +++ b/tests/test_port_call_key_20260929.py @@ -0,0 +1,37 @@ +"""판정 기록을 입항 건(port_call_key)에 묶는다 (2026-09-29). + +실측: 입항이 12시간 넘게 남은 배의 새 입항 건 옆에, 지난 입항 건(다른 화물)의 판정이 떴다(7척 전부). +""" +from app.api.v1.arrivals import _assessment_for +from app.models.assessment_history import AssessmentLevel +from app.services.assessment import _same_as_last + +LAST = {"stage": "입항전", "level": "적합", "action": None, "suggested_key": "", "condition_key": "", + "port_call_key": "DSPC6_2026_010"} + + +def test_new_port_call_is_recorded_even_if_level_same(): + assert _same_as_last(LAST, "입항전", AssessmentLevel.FIT, None, "", "", "DSPC6_2026_010") + assert not _same_as_last(LAST, "입항전", AssessmentLevel.FIT, None, "", "", "DSPC6_2026_011") + + +def test_unknown_key_does_not_compare_port_call(): + assert _same_as_last(LAST, "입항전", AssessmentLevel.FIT, None, "", "", "") + + +def test_row_gets_only_its_own_port_call_assessment(): + old = {"level": "적합", "tag": "010"} + by_key = {("DSPC6", "DSPC6_2026_010"): old} + row_next = {"call_sign": "DSPC6", "port_call_key": "DSPC6_2026_011", "current_call_key": "DSPC6_2026_010"} + assert _assessment_for(row_next, by_key) is None + row_now = {**row_next, "port_call_key": "DSPC6_2026_010"} + assert _assessment_for(row_now, by_key) is old + + +def test_legacy_record_without_key_only_for_current_call(): + legacy = {"level": "주의"} + by_key = {("DSPC6", ""): legacy} + assert _assessment_for({"call_sign": "DSPC6", "port_call_key": "DSPC6_2026_010", + "current_call_key": "DSPC6_2026_010"}, by_key) is legacy + assert _assessment_for({"call_sign": "DSPC6", "port_call_key": "DSPC6_2026_011", + "current_call_key": "DSPC6_2026_010"}, by_key) is None diff --git a/tests/test_readable_output_20260929.py b/tests/test_readable_output_20260929.py new file mode 100644 index 0000000..ecf829d --- /dev/null +++ b/tests/test_readable_output_20260929.py @@ -0,0 +1,244 @@ +"""관제사용 반환 정리 (2026-09-29) — 예보 공백, 안전 판정 근거·확인 필요, 기상 참고값.""" +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace + +import pytest + +from app.agents import tools +from app.agents.safety.prompt import build_user_prompt +from app.agents.safety.schemas import RiskLevel +from app.agents.safety.service import _needs_check, _pick_governing, _verdict_basis +from app.agents.weather import service as weather_service +from app.agents.weather.schemas import ObservationFactor, WeatherAssessmentResult, WorkStatus + +NOW = datetime(2026, 9, 29, 0, 0, tzinfo=timezone.utc) +_THRESHOLD = SimpleNamespace( + stop_wind_ms=14.0, unberth_wind_ms=17.0, disconnect_wind_ms=21.0, + stop_wave_m=None, unberth_wave_m=None, disconnect_wave_m=None, +) + + +async def _warning(monkeypatch, rows, hours): + async def fake_range(db, *, start, end): + return rows + + monkeypatch.setattr(weather_service, "get_forecast_range", fake_range) + return await weather_service._build_forecast_warning( + None, as_of=NOW, expected_completion_at=NOW + timedelta(hours=hours), + threshold=_THRESHOLD, extra_condition_active=False, + ) + + +def _row(h, wind): + return SimpleNamespace(fcst_at_utc=NOW + timedelta(hours=h), wind_speed_ms=wind, wave_height_m=None, precip_mm=0.0) + + +@pytest.mark.asyncio +async def test_null_forecast_is_gap_not_deterioration(monkeypatch): + fw = await _warning(monkeypatch, [_row(1, 3.0), _row(2, 3.0), _row(3, None), _row(4, None)], 4) + assert fw.will_deteriorate is False and fw.worst_status is WorkStatus.NORMAL + assert fw.no_forecast_after_utc == NOW + timedelta(hours=3) + + +@pytest.mark.asyncio +async def test_forecast_ending_early_is_gap(monkeypatch): + fw = await _warning(monkeypatch, [_row(1, 3.0), _row(2, 3.0)], 48) + assert fw.no_forecast_after_utc == NOW + timedelta(hours=2) + + +@pytest.mark.asyncio +async def test_forecast_wave_not_applied_to_inner_berth(monkeypatch): + # 9/29 우선호@달포부두 재현 — 풍속은 멀쩡한데 예보 파고로 "13:00 부터 '하역중단' 예보" 조건이 붙었다. + # 현재 관측과 같이, 항내 부두에는 외해 파고(예보 포함)를 쓰지 않는다. + threshold = SimpleNamespace(**{**vars(_THRESHOLD), "stop_wave_m": 1.5}) + rows = [SimpleNamespace(fcst_at_utc=NOW + timedelta(hours=1), wind_speed_ms=3.0, wave_height_m=2.0, precip_mm=0.0)] + + async def fake_range(db, *, start, end): + return rows + + monkeypatch.setattr(weather_service, "get_forecast_range", fake_range) + kw = dict(as_of=NOW, expected_completion_at=NOW + timedelta(hours=1), threshold=threshold, + extra_condition_active=False) + inner = await weather_service._build_forecast_warning(None, **kw, wave_applies=False) + buoy = await weather_service._build_forecast_warning(None, **kw, wave_applies=True) + assert inner.will_deteriorate is False + assert buoy.will_deteriorate is True + # 무엇이 기준을 넘는 예보인지 함께 싣는다 — "하역중단 예보"만으로는 원인을 몰랐다 + assert buoy.earliest_deterioration_causes == ["파고 2.0m >= 1.5m"] + + +@pytest.mark.asyncio +async def test_real_deterioration_still_counts(monkeypatch): + fw = await _warning(monkeypatch, [_row(1, 3.0), _row(2, 15.0)], 2) + assert fw.will_deteriorate is True and fw.no_forecast_after_utc is None + + +def _msds(name="케로젠", packing_group="Ⅲ", flash=None): + return SimpleNamespace(name_ko=name, name_en=None, chem_id=name, + packing_group=packing_group, flash_point_celsius=flash) + + +def _verdict(**kw): + base = dict(conflicts=[], bulk_compatibility_conflicts=[], packaging_violations=[], + unassessed_pairs=[], unload_method_name=None, target_row=_msds(), + rule_engine_floor=RiskLevel.SAFE) + base.update(kw) + return SimpleNamespace(**base) + + +def test_basis_puts_onboard_conflict_first(): + onboard = [SimpleNamespace(cargo_a_name="에틸렌디아민", cargo_b_name="아크릴산")] + lines = _verdict_basis([_verdict()], 4, onboard) + assert lines[0].startswith("같은 선박 화물끼리 혼재 충돌 1쌍(에틸렌디아민↔아크릴산)") + assert lines[-1] == "이웃 화물 4건과 MSDS·46 CFR 150 기준 충돌 없음" + + +def test_basis_no_neighbors_is_stated(): + assert _verdict_basis([_verdict()], 0, []) == ["이웃 선석에 실린 화물 없음 — 혼재 비교 대상 없음"] + + +def test_needs_check_packaging_and_unassessed(): + pair = SimpleNamespace(adjacent_name="황산", adjacent_chem_id="1", reason="MSDS 근거 없음") + grade1 = _msds(packing_group="Ⅰ") + assert _needs_check([_verdict(unassessed_pairs=[pair], target_row=grade1)]) == [ + "이웃 화물 황산 — 혼재 판정 근거 부족(MSDS 근거 없음)", + "하역방식 — 신고가 없어 포장·하역 적합성은 보지 못했습니다", + ] + assert _needs_check([_verdict(unload_method_name="펌프", target_row=grade1)]) == [] + + +@pytest.mark.parametrize("packing_group", ["Ⅱ", "Ⅲ", None]) +def test_needs_check_skips_unload_method_when_it_cannot_change_verdict(packing_group): + # 하역방식은 용기등급 Ⅰ 화물만 판정한다(rule_engine.find_packing_violation) — 나머지는 몰라도 된다 + assert _needs_check([_verdict(target_row=_msds(packing_group=packing_group))]) == [] + + +def _nitric(target_group="33"): + return ( + SimpleNamespace(adjacent_berth="옆 부두", adjacent_name="질산", adjacent_chem_id="001052", + shared_category="가연성물질"), + SimpleNamespace(adjacent_berth="옆 부두", adjacent_name="질산", adjacent_chem_id="001052", + target_group=target_group, target_group_name="Misc. Hydrocarbon Mixtures", + adjacent_group="3", adjacent_group_name="Nitric Acids"), + ) + + +def test_basis_merges_same_conflict_across_cargos_with_names(): + # 가솔린·케로젠이 둘 다 질산과 충돌 — 예전엔 대표(가솔린) 것만, 우리 화물 이름 없이 나왔다 + verdicts = [] + for name in ("가솔린", "케로젠"): + msds, cfr = _nitric() + verdicts.append(_verdict(target_row=_msds(name), conflicts=[msds], bulk_compatibility_conflicts=[cfr])) + assert _verdict_basis(verdicts, 1, []) == [ + "이웃 화물 충돌: 가솔린·케로젠 ↔ 옆 부두 질산 — MSDS '가연성물질' 혼재금지 / 46 CFR 150 " + "Misc. Hydrocarbon Mixtures(33) ↔ Nitric Acids(3)", + ] + + +def test_basis_one_line_per_adjacent_cargo(): + # 4부두 1·2선석의 질산 하나가 MSDS·46 CFR 150 두 기준으로 4줄이 되던 것(사용자 지적 9/29)을 한 줄로 + lines = [] + for berth in ("4부두-1선석", "4부두-2선석"): + msds, cfr = _nitric(target_group="30") + msds.adjacent_berth = cfr.adjacent_berth = berth + cfr.target_group_name = "Olefins" + lines.append((msds, cfr)) + v = _verdict(conflicts=[m for m, _ in lines], bulk_compatibility_conflicts=[c for _, c in lines]) + assert _verdict_basis([v], 2, []) == [ + "이웃 화물 충돌: 4부두 1·2선석 질산 — MSDS '가연성물질' 혼재금지 / 46 CFR 150 Olefins(30) ↔ Nitric Acids(3)", + ] + + +def test_basis_single_cargo_keeps_old_wording(): + msds, _ = _nitric() + assert _verdict_basis([_verdict(conflicts=[msds])], 1, []) == [ + "이웃 화물 충돌: 옆 부두 질산 — MSDS '가연성물질' 혼재금지", + ] + + +def test_needs_check_covers_non_governing_cargos(): + # 대표가 아닌 화물의 근거 부족·하역방식 누락도 빠지지 않는다 — 어느 화물인지 이름을 붙인다 + pair = SimpleNamespace(adjacent_name="황산", adjacent_chem_id="1", reason="MSDS 근거 없음") + verdicts = [ + _verdict(target_row=_msds("가솔린", "Ⅱ")), + _verdict(target_row=_msds("디젤 연료"), unassessed_pairs=[pair]), + _verdict(target_row=_msds("러버 솔벤트", "Ⅰ")), + ] + assert _needs_check(verdicts) == [ + "디젤 연료 · 이웃 화물 황산 — 혼재 판정 근거 부족(MSDS 근거 없음)", + "러버 솔벤트 · 하역방식 — 신고가 없어 포장·하역 적합성은 보지 못했습니다", + ] + + +def test_safety_prompt_checks_every_cargo_conflict(): + # 대표 화물 것만 넘기면 다른 화물의 충돌이 체크리스트에서 빠졌다 + lines = ["이웃 화물 충돌: 가솔린·케로젠 ↔ 옆 부두 질산 — MSDS '가연성물질' 혼재금지"] + p = build_user_prompt( + target_cargo_name="가솔린", hazard_summary={}, conflicts=[], bulk_compatibility_conflicts=[], + packaging_violations=[], unassessed_pairs=[], adjacent_count=1, unload_method_name="펌프", + conflict_checks=lines, + ) + assert " - 이웃 화물 충돌: 가솔린·케로젠 ↔ 옆 부두 질산" in p + + +def test_governing_tie_prefers_packing_group_then_flash_point(): + # 붙여 준 6종 화물 — 모두 '안전'이면 예전엔 목록 맨 앞(케로젠)이 대표였다 + verdicts = [_verdict(target_row=_msds(n, pg, fp)) for n, pg, fp in ( + ("케로젠", "Ⅲ", 29.0), ("디젤 연료", "Ⅲ", 58.0), ("가솔린", "Ⅱ", -40.0), + ("러버 솔벤트", "Ⅰ", None), ("스토다드 솔벤트", "Ⅱ", 21.0), ("아스팔트", "Ⅱ", 37.78), + )] + assert _pick_governing(verdicts).target_row.name_ko == "러버 솔벤트" + assert _pick_governing(verdicts[:3]).target_row.name_ko == "가솔린" # Ⅱ > Ⅲ + assert _pick_governing([verdicts[4], verdicts[2]]).target_row.name_ko == "가솔린" # 같은 Ⅱ → 인화점 낮은 쪽 + + +def test_governing_higher_risk_beats_packing_group(): + low = _verdict(target_row=_msds("러버 솔벤트", "Ⅰ")) + high = _verdict(target_row=_msds("케로젠", "Ⅲ", 29.0), rule_engine_floor=RiskLevel.DANGER) + assert _pick_governing([low, high]) is high + + +def test_governing_full_tie_keeps_request_order(): + a, b = _verdict(target_row=_msds("A", "Ⅱ", 10.0)), _verdict(target_row=_msds("B", "Ⅱ", 10.0)) + assert _pick_governing([a, b]) is a + + +def test_unassessed_pairs_fold_into_one_line_with_common_reason(): + # SH SOPHIA 재현 — 이웃 30건마다 대상 화물 쪽 자료 공백 4구절이 되풀이됐다 + common = "'X'은 46 CFR Part 150 호환성 그룹 미등재 / 'X'의 MSDS에 화물 대상 기피 정보 없음" + pairs = [SimpleNamespace(adjacent_name=n, adjacent_chem_id=n, reason=f"{common} / '{n}'의 MSDS에 기피 정보 없음") + for n in ("케로젠", "디젤", "가솔린", "나프타")] + v = _verdict(unassessed_pairs=pairs, unload_method_name="펌프") + assert _needs_check([v]) == [ + "이웃 화물 케로젠, 디젤, 가솔린 외 1종 — 혼재 판정 근거 부족" + "('X'은 46 CFR Part 150 호환성 그룹 미등재; 'X'의 MSDS에 화물 대상 기피 정보 없음)" + ] + basis = _verdict_basis([v], 30, []) + assert basis[0].startswith("이웃 화물 4종은 혼재 판정 근거가 없음") + assert basis[-1] == "판정 근거가 있는 이웃 화물과는 MSDS·46 CFR 150 기준 충돌 없음" + + +def test_safety_prompt_has_no_grade_and_lists_what_to_check(): + onboard = [SimpleNamespace(cargo_a_name="A", cargo_b_name="B", basis="46 CFR 150", detail="아민↔아크릴레이트")] + p = build_user_prompt( + target_cargo_name="큐멘", hazard_summary={}, conflicts=[], bulk_compatibility_conflicts=[], + packaging_violations=[], unassessed_pairs=[], adjacent_count=4, unload_method_name=None, + onboard_conflicts=onboard, needs_check=["하역방식 — 신고가 없어 포장·하역 적합성은 보지 못했습니다"], + ) + # 등급을 주면 LLM 이 그 원인을 유해성에서 찾았다 — 등급 자체를 넘기지 않는다 + assert "확정 위험등급" not in p + assert "A ↔ B 같은 선박 적재 — 격리 적재 확인" in p and "하역방식 — 신고가 없어" in p + + +def test_weather_reference_value_is_not_evidence(): + obs = ObservationFactor(value=1.2, unit="m/s", is_stale=False) + w = WeatherAssessmentResult( + status=WorkStatus.NORMAL, assessed_at_utc=NOW, wind=obs, wave=obs, + reasons=["풍속 1.2m/s < 14.0m/s(중단 임계) - 정상", + "파고 0.3m는 외해 부이(22189) 관측이라 이 항내 계선시설 판정에 적용하지 않음 - 참고값"], + ) + o = tools.weather_opinion(w) + assert o.evidence == ["풍속 1.2m/s < 14.0m/s(중단 임계) - 정상"] + # 판정에 안 쓴 파고는 '확인한 것'이 아니라 참고(notes)다 — 칩에 있으면 확인한 근거처럼 읽혔다 + assert o.notes and "적용하지 않음" in o.notes[0] + assert not any("파고" in c for c in o.checked) diff --git a/tests/test_safety_prompt_20260927.py b/tests/test_safety_prompt_20260927.py index 07f5094..e65b8b1 100644 --- a/tests/test_safety_prompt_20260927.py +++ b/tests/test_safety_prompt_20260927.py @@ -11,7 +11,7 @@ def _prompt(**kw): base = dict( target_cargo_name="벤젠", hazard_summary={}, conflicts=[], bulk_compatibility_conflicts=[], - packaging_violations=[], unassessed_pairs=[], rule_engine_floor=RiskLevel.SAFE, + packaging_violations=[], unassessed_pairs=[], adjacent_count=0, unload_method_name=None, ) base.update(kw) @@ -49,7 +49,7 @@ def test_packaging_checked_with_unload_method(): def _verdict(**kw): base = dict( - target_row=SimpleNamespace(name_ko="벤젠", chem_id="x", cas_no=None), conflicts=[], + target_row=SimpleNamespace(name_ko="벤젠", chem_id="x", cas_no=None, packing_group="Ⅱ"), conflicts=[], bulk_compatibility_conflicts=[], packaging_violations=[], unassessed_pairs=[], rule_engine_floor=RiskLevel.SAFE, unload_method_name=None, ) @@ -58,11 +58,89 @@ def _verdict(**kw): def test_facts_no_neighbors_and_no_packaging_check(): - s = _checked_facts(_verdict(), 0) + # [2026-09-29] 하역방식 미신고 문장은 용기등급 Ⅰ 화물에만 — 나머지는 하역방식이 판정을 바꾸지 않는다 + s = _checked_facts([_verdict()], 0, [], RiskLevel.SAFE) assert s.startswith("[혼재 판정 안전") - assert "혼재 검사 대상이 없었습니다" in s and "검사하지 않았습니다" in s + assert "혼재 검사 대상이 없었습니다" in s and "하역방식" not in s + grade1 = SimpleNamespace(name_ko="아크릴로니트릴", chem_id="y", cas_no=None, packing_group="Ⅰ") + assert "하역방식이 신고되지 않아" in _checked_facts([_verdict(target_row=grade1)], 0, [], RiskLevel.SAFE) def test_facts_neighbors_clear(): - s = _checked_facts(_verdict(unload_method_name="펌프"), 4) - assert "인접 화물 4건과" in s and "충돌이 없습니다" in s and "부적합은 없습니다" in s + s = _checked_facts([_verdict(unload_method_name="펌프")], 4, [], RiskLevel.SAFE) + assert "이웃 화물 4건과" in s and "충돌이 없습니다" in s and "부적합은 없습니다" in s + + +# ── 여러 화물: 모두 안전이면 종합, 아니면 문제 화물만 (2026-09-29) ── +from app.agents.safety.service import _focus # noqa: E402 + + +def _named(name, level=RiskLevel.SAFE, **kw): + row = SimpleNamespace(name_ko=name, chem_id=name, cas_no=None, packing_group="Ⅱ") + return _verdict(target_row=row, rule_engine_floor=level, unload_method_name="펌프", **kw) + + +def test_all_safe_cargos_are_described_together(): + vs = [_named("염화비닐"), _named("아이소부텐")] + focus = _focus(vs, []) + assert [x.target_row.name_ko for x in focus] == ["염화비닐", "아이소부텐"] + s = _checked_facts(focus, 8, [], RiskLevel.SAFE) + assert s.startswith("[혼재 판정 안전 · 화물 2종 종합]") and "염화비닐" not in s + + +def test_only_risky_cargo_is_described(): + vs = [_named("케로젠"), _named("러버 솔벤트", RiskLevel.CAUTION), _named("가솔린")] + focus = _focus(vs, []) + assert [x.target_row.name_ko for x in focus] == ["러버 솔벤트"] + assert _checked_facts(focus, 8, [], RiskLevel.CAUTION).startswith("[혼재 판정 주의]\n") + + +def test_onboard_conflict_names_the_pair(): + onboard = [SimpleNamespace(cargo_a_name="황산", cargo_b_name="가솔린")] + vs = [_named("케로젠"), _named("황산"), _named("가솔린")] + assert {x.target_row.name_ko for x in _focus(vs, onboard)} == {"황산", "가솔린"} + + +# ── 이웃 화물 이름 · 머리 뒤 줄바꿈 · GHS 주요 위험성 (2026-09-29) ── +from app.agents.safety.service import _hazard_lines # noqa: E402 + + +def test_neighbor_names_and_header_newline(): + s = _checked_facts([_verdict(adjacent_names=["가솔린", "케로젠"], unload_method_name="펌프")], 5, [], RiskLevel.SAFE) + head, body = s.split("\n", 1) + assert head == "[혼재 판정 안전]" + assert body.startswith("이웃 화물 5건(가솔린, 케로젠)과") + + +def _ghs(name, classes): + payload = {"detail02": {"data": [{"msdsItemCode": "B02", "itemDetail": "|".join(f"{k} : {g}" for k, g in classes)}]}} + return SimpleNamespace(target_row=SimpleNamespace(name_ko=name, chem_id=name, cas_no=None, packing_group="Ⅲ", + msds_payload=payload)) + + +def test_hazards_follow_ghs_per_cargo(): + # 실측 오류 재현: 디젤·케로젠(구분3)을 '고인화성'으로 묶고, 없는 '눈 자극'을 쓰던 문제 + focus = [ + _ghs("러버 솔벤트", [("인화성 액체", "구분1"), ("발암성", "구분1B"), ("흡인 유해성", "구분1")]), + _ghs("가솔린", [("인화성 액체", "구분2"), ("발암성", "구분1B"), ("흡인 유해성", "구분1")]), + _ghs("케로젠", [("인화성 액체", "구분3"), ("발암성", "구분2"), ("흡인 유해성", "구분1")]), + _ghs("디젤 연료", [("인화성 액체", "구분3"), ("발암성", "구분2"), ("피부 부식성/피부 자극성", "구분2")]), + ] + lines = _hazard_lines(focus) + assert lines[0] == "인화성 액체: 러버 솔벤트(극인화성) · 가솔린(고인화성) · 케로젠·디젤 연료(인화성)" + assert "흡인 유해성: 러버 솔벤트·가솔린·케로젠(1)" in lines + assert "발암성: 러버 솔벤트·가솔린(1B) · 케로젠·디젤 연료(2, 의심)" in lines + assert not any("눈" in l for l in lines) + + +def test_single_cargo_hazards_have_no_name(): + assert _hazard_lines([_ghs("가솔린", [("인화성 액체", "구분2")])]) == ["인화성 액체 고인화성"] + + +def test_hazard_order_and_inhalation_parse(): + # 센 구분이 앞 · 분류명 안 콜론('흡입: 증기')에서 자르지 않는다 + from app.agents.safety.msds_context import ghs_classification + payload = {"detail02": {"data": [{"msdsItemCode": "B02", "itemDetail": "급성 독성(흡입: 증기) : 구분4"}]}} + assert ghs_classification(payload) == [("급성 독성(흡입: 증기)", "구분4")] + lines = _hazard_lines([_ghs("디젤 연료", [("인화성 액체", "구분3")]), _ghs("가솔린", [("인화성 액체", "구분2")])]) + assert lines == ["인화성 액체: 가솔린(고인화성) · 디젤 연료(인화성)"] diff --git a/tests/test_supervisor_20260927.py b/tests/test_supervisor_20260927.py index 77cf0fb..d84e9bc 100644 --- a/tests/test_supervisor_20260927.py +++ b/tests/test_supervisor_20260927.py @@ -3,6 +3,7 @@ DB·그래프 없이 도구 계층을 바꿔치기해 오케스트레이터의 흐름만 본다. """ from datetime import datetime, timedelta, timezone +from types import SimpleNamespace import pytest @@ -45,7 +46,7 @@ def _weather(forecast: ForecastWarning | None) -> WeatherAssessmentResult: ) -def _patch(monkeypatch, forecast): +def _patch(monkeypatch, forecast, **safety): async def fake_berth(*a, **k): return BerthCheck(candidate=BerthCandidate( rank=1, berth_id="효성부두", wharf_name="효성부두", port_name="온산항", depth_m=12.0, @@ -56,11 +57,13 @@ async def fake_weather(db, **k): return _weather(forecast) async def fake_segregation(*a, **k): - return SafetyAssessmentResult.model_construct( + return SafetyAssessmentResult.model_construct(**{**dict( target_cargo_name="벤젠", risk_level=RiskLevel.SAFE, rule_engine_floor=RiskLevel.SAFE, conflicts=[], bulk_compatibility_conflicts=[], packaging_violations=[], unassessed_pairs=[], cargo_verdicts=[], checklist=[], key_hazards=[], reasoning="", - ) + onboard_conflicts=[], adjacent_count=3, verdict_basis=["이웃 화물 3건과 MSDS·46 CFR 150 기준 충돌 없음"], + needs_check=[], cargo_profile="", + ), **safety}) monkeypatch.setattr(orch.tools, "check_berth", fake_berth) monkeypatch.setattr(orch.tools, "check_weather", fake_weather) @@ -97,3 +100,117 @@ async def test_no_forecast_deterioration_has_no_condition(monkeypatch): assert result.conditions == [] and result.condition_key == "" level, headline = level_from_decision(result) assert level is AssessmentLevel.FIT and "조건부" not in headline + + +# ── 2026-09-29 관제사용 반환 정리 ────────────────────────────────────────────── + +class _EchoLLM: + """받은 입력을 남기고 정해 둔 문장을 돌려준다.""" + + def __init__(self, text: str): + self.text, self.prompts = text, [] + + async def generate_structured(self, *, user_prompt, schema, **_): + self.prompts.append(user_prompt) + return schema(summary=self.text) + + +@pytest.mark.asyncio +async def test_forecast_gap_is_needs_check_not_condition(monkeypatch): + # ATHENS SPIRIT 재현 — 예보가 끝난 뒤를 '판단불가 예보(악화)'로 잡아 없는 경고가 붙었다 + gap_at = NOW + timedelta(hours=40) + _patch(monkeypatch, ForecastWarning( + window_end_utc=NOW + timedelta(hours=120), forecast_points_checked=40, + will_deteriorate=False, worst_status=WorkStatus.NORMAL, no_forecast_after_utc=gap_at, points=[], + )) + result = await orch.orchestrate(None, None, _SummaryLLM(), _request()) + + assert result.conditions == [] and result.level == "적합" + assert any("예보가 아직 나오지 않았습니다" in m for m in result.needs_check) + + +@pytest.mark.asyncio +async def test_onboard_caution_level_and_basis(monkeypatch): + # GOLDEN DENISE 재현 — 같은 선박 화물 충돌로 '주의'인데 콘솔은 초록 '적합', 혼재 근거는 비어 있었다 + _patch(monkeypatch, None, risk_level=RiskLevel.CAUTION, rule_engine_floor=RiskLevel.CAUTION, + cargo_verdicts=[SimpleNamespace(risk_level=RiskLevel.SAFE)], # 이웃 기준으로는 안전 + onboard_conflicts=[object()], verdict_basis=["같은 선박 화물끼리 혼재 충돌 1쌍(A↔B) — 격리 적재 확인 필요"]) + result = await orch.orchestrate(None, None, _SummaryLLM(), _request()) + + assert result.overall_decision is OverallDecision.APPROVED # 귀결 어휘는 그대로 + assert result.level == "주의" and "같은 선박" in result.headline + seg = next(o for o in result.opinions if o.axis == "혼재") + assert seg.level == "주의" and "같은 선박" in seg.evidence[0] + + +@pytest.mark.asyncio +async def test_empty_llm_summary_falls_back_to_template(monkeypatch): + _patch(monkeypatch, None, needs_check=["하역방식 — 신고가 없어 포장·하역 적합성은 보지 못했습니다"]) + result = await orch.orchestrate(None, None, _SummaryLLM(), _request()) + + assert result.summary == "이 선석이 이 선박·화물 조건에 맞습니다. 확인 필요: 하역방식." + + +@pytest.mark.asyncio +async def test_llm_prompt_carries_facts_only(monkeypatch): + _patch(monkeypatch, None) + llm = _EchoLLM("선석·기상·혼재 모두 기준 안입니다.") + req = _request().model_copy(update={"target_source": "AIS", "reported_wharf_name": "정박지-M2"}) + result = await orch.orchestrate(None, None, llm, req) + + assert result.summary == "선석·기상·혼재 모두 기준 안입니다." + assert result.headline.startswith("지금 접안한 선석이") + assert result.assignment_trace[0].startswith("지금 접안한 '효성부두' 확인") + assert "PORT-MIS 신고 계류시설: '정박지-M2'" in result.assignment_trace + prompt = llm.prompts[0] + # 비어 있는 칸은 넘기지 않는다 — 넘기면 "(해당 없음)"을 문장에 옮겨 적었다 + assert "[확인 필요]" not in prompt and "[조건]" not in prompt and "[대체 선석 제안]" not in prompt + for gone in ("순위", "배정 경로", "탈락", "점유"): + assert gone not in prompt + + +@pytest.mark.asyncio +@pytest.mark.parametrize("text", [ + "최종 추천 선석인 효성부두는 수심과 흘수여유가 충분합니다.", # 9/28 판정 기록에 실제로 남은 문장 + "효성부두 배정이 적합합니다.", + "최적 선석으로 선정되었습니다.", +]) +async def test_llm_summary_with_assignment_words_falls_back_to_template(monkeypatch, text): + # 우리는 배정·추천을 하지 않는다(검증만) — 프롬프트로 금지해도 모델이 가끔 쓴다 + _patch(monkeypatch, None) + result = await orch.orchestrate(None, None, _EchoLLM(text), _request()) + + assert result.summary == "이 선석이 이 선박·화물 조건에 맞습니다." + + +@pytest.mark.asyncio +async def test_summarize_false_skips_summary_llm(monkeypatch): + _patch(monkeypatch, None) + llm = _EchoLLM("불리면 안 되는 문장") + result = await orch.orchestrate(None, None, llm, _request(), summarize=False) + + assert llm.prompts == [] and result.summary == "이 선석이 이 선박·화물 조건에 맞습니다." + + +@pytest.mark.asyncio +async def test_unknown_weather_is_unknown_level_without_llm(monkeypatch): + async def fake_berth(*a, **k): + return BerthCheck(candidate=BerthCandidate( + rank=1, berth_id="효성부두", wharf_name="효성부두", port_name="온산항", depth_m=12.0, + draught_margin_m=3.0, occupancy_status=OccupancyStatus.AVAILABLE, + )) + + async def stale_weather(db, **k): + obs = ObservationFactor(value=2.5, unit="m/s", is_stale=True) + return WeatherAssessmentResult(status=WorkStatus.UNKNOWN, assessed_at_utc=NOW, wind=obs, wave=obs, + reasons=["풍속 관측치 없음 또는 기준 시각 대비 오래됨 - 판단 불가"]) + + monkeypatch.setattr(orch.tools, "check_berth", fake_berth) + monkeypatch.setattr(orch.tools, "check_weather", stale_weather) + llm = _EchoLLM("불리면 안 되는 문장") + result = await orch.orchestrate(None, None, llm, _request()) + + # 예전엔 빨간 '기상불가_중단권고'로 보였다 — 근거 부족은 판정불가다 + assert result.level == "판정불가" and llm.prompts == [] + assert result.summary.endswith("빠진 근거를 채운 뒤 다시 판정해야 합니다.") + assert "풍속 — 관측이 끊겼거나 오래됐습니다" in result.needs_check