Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
76 changes: 76 additions & 0 deletions alembic/sql/0035_current_call_portmis_newer.sql
Original file line number Diff line number Diff line change
@@ -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';
58 changes: 58 additions & 0 deletions alembic/sql/0035_current_call_portmis_newer_down.sql
Original file line number Diff line number Diff line change
@@ -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';
39 changes: 39 additions & 0 deletions alembic/versions/0035_current_call_portmis_newer.py
Original file line number Diff line number Diff line change
@@ -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")
13 changes: 8 additions & 5 deletions app/agents/chatbot/graph_queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,16 +158,18 @@ 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)

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"],
Expand All @@ -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
Expand All @@ -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"],
}
Expand Down
13 changes: 9 additions & 4 deletions app/agents/chatbot/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ def build_followup_prompt(question: str, evidence_text: str) -> str:
쓰세요. 임의로 등급을 올리거나 내리지 마세요. 그 판정은 규칙엔진 하한(MSDS
혼재금지·벌크 호환성그룹·포장기준·판정가능성)으로 보정된 값입니다. 근거로는
충돌이 적힌 축을 인용하세요 — 한 축이 "충돌 없음"이어도 다른 축이 등급을 정했을 수 있습니다.
"[참고"로 시작하는 항목(IMDG 격리표 등)은 등급의 근거로 인용하지 마세요.
근거에 적힌 화물쌍만 말하고, "울산항에서 ○○의 혼재금지 대상은 A와 B" 같은 일반화는 하지 마세요.
5. 수치(인화점, 끓는점 등)는 근거에 적힌 값과 단위를 그대로 인용하세요. 환산·반올림
하지 마세요.
6. [근거 · 항만 운영 현황]이 있으면 배 이름·부두·화물·판정 등급·시각을 적힌 그대로
Expand Down Expand Up @@ -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 혼재금지 근거가 없어 "
Expand Down
26 changes: 23 additions & 3 deletions app/agents/chatbot/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading