From cedc6e99f1356aea659a4e4fca9f80f0cc531b16 Mon Sep 17 00:00:00 2001 From: wagurano Date: Sat, 18 Jul 2026 12:05:00 +0900 Subject: [PATCH 01/24] =?UTF-8?q?=EA=B2=80=EC=83=89=20=EB=B0=A9=EB=B2=95?= =?UTF-8?q?=EC=9D=84=20=EB=8B=A4=EB=93=AC=EC=8A=B5=EB=8B=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/workercare-search.plan.md | 188 ++++++++++++++++++++++----------- 1 file changed, 127 insertions(+), 61 deletions(-) diff --git a/docs/workercare-search.plan.md b/docs/workercare-search.plan.md index 149fc7f..821a77c 100644 --- a/docs/workercare-search.plan.md +++ b/docs/workercare-search.plan.md @@ -74,35 +74,37 @@ | DB 컬럼 (snake_case) | 원본 CSV 컬럼 | 타입 | 인덱스 | 검색/필터 용도 | |----------------------|--------------|------|--------|---------------| | `job_name` | 직종명 | `string` | ✅ | 직업 필터, FTS5 | -| `job_description` | 담당_업무 | `text` | — | 하는일 필터, FTS5 | +| `job_description` | 담당_업무 | `text` | — | 하는일 필터, FTS5. **주의**: 아래 5.3/5.4절 코드 예시에서도 반드시 `job_description`으로 통일해서 참조한다 (`duty_description`이라는 이름은 이 문서 어디에도 쓰지 않는다 — 실제 컬럼이 존재하지 않아 strong params가 값을 버리고 FTS/임포트 코드가 없는 컬럼을 참조해 런타임 에러가 난다) | | `employment_type` | 고용_형태 | `string` | ✅ | 고용 형태 필터 (드롭다운) | | `work_type` | 근무_형태 | `string` | ✅ | 근무 형태 필터 | -| `job_tenure` | 현_직종_총_종사기간 | `string` | — | 기간 표시 (원본 단위 보존) | +| `job_tenure_months` | 현_직종_총_종사기간 | `integer` | — | `wip/cerebras_prompts.rb`의 추출 스키마상 숫자(개월)이므로 `string`이 아닌 `integer`로 저장 | | `weekly_work_hours` | 1주_평균_근무시간 | `decimal` | — | 통계/표시 | | `daily_work_hours` | 1일_평균_근무시간 | `decimal` | — | 통계/표시 | -| `burden_body_part` | 부담_신체_부위 | `string` | ✅ | 아픈 신체 부위 필터 | -| `bad_posture` | 주요_부적절한_자세 | `string` | — | 부적절 자세 표시 | +| `burden_body_part` | 부담_신체_부위 | `text` | — | **다중값**: 추출 스키마상 배열이며 실제 CSV에도 파이프(`\|`)로 구분되어 저장됨(예: `"목\|상체\|하체"`). 파이프 구분 문자열 그대로 저장하고, 필터는 exact match(`where(burden_body_part: ...)`)도, 단순 `LIKE '%값%'`도 아닌 **파이프 경계를 인식하는 LIKE**로 처리한다 (5.3절 참고) — 단순 substring 매칭은 CSV에 이미 공존하는 "목"/"뒷목"/"손목"/"발목" 때문에 "목" 선택 시 "손목"만 있는 레코드까지 잘못 걸린다. 부분 문자열 매칭이라 인덱스 효과가 없어 인덱스는 만들지 않는다 | +| `bad_posture` | 주요_부적절한_자세 | `text` | — | 다중값(파이프 구분), 표시용 | | `heavy_lifting` | 중량물_취급_여부 | `string` (Y/N) | ✅ | 중량물 취급 필터 | -| `max_item_weight` | 취급_물품_최대_무게 | `string` | — | 무게 표시 | -| `daily_total_weight` | 1일_취급_총_누적_중량 | `string` | — | 중량 표시 | -| `other_harmful_factors` | 기타_유해요인_노출 | `text` | — | 유해요인 표시, FTS5 후보 | -| `work_relevance_eval` | 업무관련성_평가 | `string` | ✅ | 업무관련성 필터 (낮음/보통/높음/매우_높음) | -| `aggravating_factors` | 업묵부담_가중요인_노출 | `text` | — | 가중요인 표시, FTS5 후보 | -| `main_reasoning` | 판단_주요_근거 | `text` | — | 판단 근거 FTS5 | +| `max_item_weight` | 취급_물품_최대_무게 | `decimal` | — | 추출 스키마상 숫자(kg) | +| `daily_total_weight` | 1일_취급_총_누적_중량 | `decimal` | — | 추출 스키마상 숫자(kg) | +| `other_harmful_factors` | 기타_유해요인_노출 | `text` | — | 다중값(파이프 구분), 표시, FTS5 후보 | +| `work_relevance_eval` | 업무관련성_평가 | `string` | ✅ | 업무관련성 필터. **주의**: 추출 스키마(`wip/cerebras_prompts.rb`)상 허용값은 6개 — `매우_높음/높음/보통/낮음/매우_낮음/미흡`. 4개만 나열하면 `매우_낮음`/`미흡` 데이터가 필터로 찾을 수 없게 된다 | +| `aggravating_factors` | 업묵부담_가중요인_노출 | `text` | — | 다중값(파이프 구분, 원본 컬럼명의 오타 "업묵부담"은 그대로 둠), FTS5 후보 | +| `main_reasoning` | 판단_주요_근거 | `text` | — | 다중값(파이프 구분), FTS5 | | `death_status` | 사망_여부 | `string` (Y/N) | ✅ | **사망 여부 필터** | | `application_type` | 신청서_종류 | `string` | ✅ | **신청서 유형 필터** | > **참고**: `boolean` 대신 `string`로 Y/N을 저장하면 원본 그대로 유지되며, 나중에 `enum`으로 전환하기도 쉽다. SQLite boolean은 사실상 integer라 차이 미미. +> **숫자 필드 주의**: `job_tenure_months`/`max_item_weight`/`daily_total_weight`를 문자열로 저장하면 정렬·범위 필터·유효성 검증에서 숫자 semantics를 잃는다. 반드시 `integer`/`decimal`로 저장한다. ### 3.2 Enum 후보 (데이터 분석 후 결정) 아래 컬럼은 cardinality가 낮으면 enum으로 전환하여 필터 UI 일관성을 높인다. - `employment_type`: `["상용직", "일용직", ""]` (빈 값은 미상) -- `work_type`: `["고정 주간근무", "교대근무", "주간고정근무", ...]` (데이터 profiling 후 확정) -- `work_relevance_eval`: `["낮음", "보통", "높음", "매우_높음"]` +- `work_type`: `["고정 주간근무", "교대근무", "주간고정근무", ...]` (데이터 profiling 후 확정 — 실제 값은 `"주간고정근무 (일부 야간작업)"`처럼 괄호 부가 설명이 붙어 자유 텍스트에 가까움) +- `work_relevance_eval`: `["매우_높음", "높음", "보통", "낮음", "매우_낮음", "미흡"]` (`wip/cerebras_prompts.rb`의 추출 스키마 기준 6개 전부 — 4개만 쓰면 `매우_낮음`/`미흡` 데이터를 필터로 찾을 수 없다) - `death_status`: `["Y", "N"]` → boolean enum 처리 가능 - `application_type`: `["요양급여", "유족급여 및 장의비", "요양급여신청서", "유족급여 및 장의비청구서", ...]` (정규화 필요) +- `burden_body_part`/`bad_posture`: 파이프 구분 다중값이므로 단일 컬럼 enum으로 만들지 않는다. 체크박스 목록은 임포트 후 `distinct` 값을 `"|"`로 split해 profiling한 뒤 확정한다. --- @@ -115,13 +117,15 @@ ### 4.1 `KscoCode` 모델 (`db/migrate/xxx_create_ksco_codes.rb`) +> **컬럼명 확정**: `code`에 대한 이름 컬럼은 `level4_name`이 아니라 `name`으로, `level1_name`/`level2_name`/`level3_name`은 각각 `major`/`submajor`/`minor`로 만든다 (아래 표·마이그레이션·모델·자동완성 예시 전부 이 이름을 사용). + | 컬럼 | 타입 | 제약 | 설명 | |------|------|------|------| | `code` | `string` | PK | 4자리 코드 (예: 9414) | -| `level4_name` | `string` | — | 세분류 (예: 재활용품 및 쓰레기 수거원) | -| `level3_name` | `string` | — | 소분류 (예: 재활용 처리 및 소각로 조작원) | -| `level2_name` | `string` | — | 중분류 | -| `level1_name` | `string` | — | 대분류 (예: 관리자) | +| `name` | `string` | — | 세분류 (was `level4_name`, 예: 재활용품 및 쓰레기 수거원) | +| `minor` | `string` | — | 소분류 (was `level3_name`, 예: 재활용 처리 및 소각로 조작원) | +| `submajor` | `string` | — | 중분류 (was `level2_name`) | +| `major` | `string` | — | 대분류 (was `level1_name`, 예: 관리자) | | `job_examples` | `text` | — | 직업예시 (파이프 구분) | | `exclusions` | `text` | — | 직업제외 | @@ -139,10 +143,10 @@ end ```ruby create_table :ksco_codes, id: false do |t| t.string :code, primary_key: true - t.string :level4_name - t.string :level3_name - t.string :level2_name - t.string :level1_name + t.string :name # 세분류 (was level4_name) + t.string :minor # 소분류 (was level3_name) + t.string :submajor # 중분류 (was level2_name) + t.string :major # 대분류 (was level1_name) t.text :job_examples t.text :exclusions end @@ -175,8 +179,8 @@ has_many :ksco_codes, through: :disease_case_ksco_codes ### 4.3 직업 필터 검색 시나리오 1. **직종명 텍스트 검색**: `job_name` 컬럼 LIKE/FTS5 검색. -2. **KSCO 코드 필터**: 사용자가 KSCO 분류(대분류→중분류→소분류→세분류)를 선택하면, 해당 코드를 가진 `DiseaseCase`를 `JOIN`으로 검색. -3. **자동완성**: `KscoCode.level4_name`을 대상으로 prefix 검색하여 드롭다운 제안. +2. **KSCO 코드 필터**: 사용자가 KSCO 분류(대분류`major`→중분류`submajor`→소분류`minor`→세분류`name`)를 선택하면, 해당 코드를 가진 `DiseaseCase`를 `JOIN`으로 검색. +3. **자동완성**: `KscoCode.name`을 대상으로 prefix 검색하여 드롭다운 제안. --- @@ -241,10 +245,10 @@ class DiseaseCasesController < ApplicationController def main_search_params params.permit( - :q, :job_name, :duty_description, :burden_body_part, + :q, :job_name, :job_description, :death_status, :application_type, :employment_type, :work_type, :work_relevance_eval, :sort, :commit, :search, - ksco_code: [] + burden_body_part: [], ksco_code: [] ) end end @@ -255,6 +259,7 @@ end 기존 `DiseaseCases::Searchable`는 `/search` (legacy)용으로 그대로 유지. 새 concern `DiseaseCases::MainSearchable`를 생성하여 `/` (main)용 로직을 분리. > **리팩토링 주의**: `normalize_query`, `build_fts_query`, `sanitize_sql_like` 기반 substring fallback 등은 두 concern에서 **동일한 로직**이다. 구현 시 `DiseaseCases::Searchable`의 private 메서드를 protected로 변경하거나, 별도 `DiseaseCases::QueryBuilder` 모듈로 추출하여 양쪽 concern에서 `include`/`delegate`로 재사용한다. 코드 중복을 피해야 향후 한국어 파티클 처리, SQL Injection 방지 등의 수정이 한 곳에서만 이루어진다. +> **상수 lookup 주의**: `normalize_query`는 메서드이므로 공유 모듈에 옮기고 `include`해도 ancestry를 통해 정상 호출된다. 하지만 `KOREAN_PARTICLES`는 상수이고, Ruby의 비한정(unqualified) 상수 조회는 호출부의 **lexical scope**를 우선 따른다. 그래서 `KOREAN_PARTICLES`를 `QueryBuilder` 쪽으로 옮기고 `Searchable#build_fts_query`에 남아있는 `token.gsub(KOREAN_PARTICLES, "")` 같은 비한정 참조를 그대로 두면 `include`만으로는 해결되지 않고 `NameError`가 난다. 상수까지 옮기려면 남은 참조를 `DiseaseCases::QueryBuilder::KOREAN_PARTICLES`처럼 완전한 이름으로 바꾸거나, 더 안전하게 이번에는 `KOREAN_PARTICLES` 상수는 그대로 두고 `normalize_query` 메서드만 공유 모듈로 추출한다. ```ruby # app/models/concerns/disease_cases/main_searchable.rb @@ -264,7 +269,7 @@ module DiseaseCases MAIN_SEARCHABLE_COLUMNS = %w[ job_name - duty_description + job_description main_reasoning other_harmful_factors aggravating_factors @@ -308,12 +313,31 @@ module DiseaseCases scope = scope.where("job_name LIKE ?", "%#{safe}%") end - if params[:duty_description].present? - safe = sanitize_sql_like(params[:duty_description]) - scope = scope.where("duty_description LIKE ?", "%#{safe}%") + if params[:job_description].present? + safe = sanitize_sql_like(params[:job_description]) + scope = scope.where("job_description LIKE ?", "%#{safe}%") + end + + # 부담_신체_부위는 파이프(|)로 구분된 다중값 텍스트다(예: "목|상체|하체"). + # exact match(where(burden_body_part: ...))는 다른 값과 같이 저장된 케이스를 놓치므로 LIKE로 매칭해야 하지만, + # 단순 LIKE '%값%'은 부분 문자열까지 잡아 오검색을 낸다 — 실제 CSV에 "목", "뒷목", "손목", "발목"이 전부 + # 존재하므로 "목"으로 LIKE '%목%' 검색하면 "손목"/"발목"/"뒷목"만 있는 레코드까지 잘못 걸린다. + # 파이프 구분자를 토큰 경계로 취급해 정확히 그 토큰만 매칭한다: 정확히 일치, 맨앞(다음이 |), + # 맨뒤(앞이 |), 중간(양쪽이 |) 네 가지 경우를 모두 확인한다. + if params[:burden_body_part].present? + values = Array(params[:burden_body_part]).reject(&:blank?) + if values.any? + conditions = values.map { + "(burden_body_part = ? OR burden_body_part LIKE ? OR burden_body_part LIKE ? OR burden_body_part LIKE ?)" + }.join(" OR ") + binds = values.flat_map { |v| + safe = sanitize_sql_like(v) + [v, "#{safe}|%", "%|#{safe}", "%|#{safe}|%"] + } + scope = scope.where(conditions, *binds) + end end - scope = scope.where(burden_body_part: params[:burden_body_part]) if params[:burden_body_part].present? scope = scope.where(death_status: params[:death_status]) if params[:death_status].present? scope = scope.where(application_type: params[:application_type]) if params[:application_type].present? scope = scope.where(employment_type: params[:employment_type]) if params[:employment_type].present? @@ -350,7 +374,7 @@ module DiseaseCases def substring_job_fallback(tokens) safe_tokens = tokens.first(2).map { |token| sanitize_sql_like(token) } - conditions = safe_tokens.map { "job_name LIKE ? OR duty_description LIKE ?" }.join(" AND ") + conditions = safe_tokens.map { "job_name LIKE ? OR job_description LIKE ?" }.join(" AND ") binds = safe_tokens.flat_map { |token| ["%#{token}%", "%#{token}%"] } where(conditions, *binds) end @@ -372,7 +396,7 @@ class CreateDiseaseCasesExtractedFts < ActiveRecord::Migration[8.1] CREATE VIRTUAL TABLE disease_cases_extracted_fts USING fts5( job_name, - duty_description, + job_description, main_reasoning, other_harmful_factors, aggravating_factors, @@ -387,16 +411,18 @@ class CreateDiseaseCasesExtractedFts < ActiveRecord::Migration[8.1] CREATE TRIGGER disease_cases_extracted_fts_insert AFTER INSERT ON disease_cases BEGIN INSERT INTO disease_cases_extracted_fts( - rowid, job_name, duty_description, main_reasoning, + rowid, job_name, job_description, main_reasoning, other_harmful_factors, aggravating_factors ) VALUES ( - new.id, new.job_name, new.duty_description, new.main_reasoning, + new.id, new.job_name, new.job_description, new.main_reasoning, new.other_harmful_factors, new.aggravating_factors ); END; SQL - # ... UPDATE/DELETE triggers 동일 패턴 + # UPDATE/DELETE triggers는 db/migrate/20260326000002_create_disease_cases_fts.rb의 + # disease_cases_fts_delete/disease_cases_fts_update 트리거와 동일한 패턴(delete pseudo-row 후 재삽입)을 + # 위 5개 컬럼(job_name, job_description, main_reasoning, other_harmful_factors, aggravating_factors)으로 그대로 적용한다. end end ``` @@ -410,8 +436,8 @@ end | 필터 | 입력 방식 | 대상 컬럼/관계 | 비고 | |------|----------|---------------|------| | **직업 (직종명)** | 텍스트 입력 + KSCO 자동완성 | `job_name` (FTS5) / `ksco_codes` (JOIN) | KSCO 선택 시 해당 코드 연결된 판정서 검색 | -| **하는일 (담당 업무)** | 텍스트 입력 | `duty_description` (FTS5) | 한국어 조사 제거 적용 | -| **아픈 신체 부위** | 드롭다운 / 멀티셀렉트 | `burden_body_part` | 데이터 profiling 후 distinct 값으로 enum 또는 string 필터 | +| **하는일 (담당 업무)** | 텍스트 입력 | `job_description` (FTS5) | 한국어 조사 제거 적용 | +| **아픈 신체 부위** | 체크박스 멀티셀렉트 | `burden_body_part` (파이프 구분 다중값) | 단일 exact match도 단순 `LIKE '%값%'`도 아님 — "목"이 "손목"/"발목"/"뒷목"까지 잘못 매칭되지 않도록 파이프 경계 인식 LIKE로 매칭 (5.3절). 체크박스 후보 목록은 임포트 후 distinct 값을 `"\|"` split해 profiling | | **신청서 유형** | 드롭다운 | `application_type` | 정규화된 enum 값 | | **사망 여부** | 스위치 / 체크박스 | `death_status` | Y/N | | **Full-text 검색** | 텍스트 입력 | `disease_cases_extracted_fts` (5개 컬럼) | 기존 FTS5 패턴 재사용 | @@ -421,7 +447,7 @@ end ### 6.2 KSCO 필터 상호작용 1. 사용자가 "버스 운전원" 입력. -2. 자동완성이 `KscoCode.where("level4_name LIKE ?", "버스 운전원%")`로 제안. +2. 자동완성이 `KscoCode.where("name LIKE ?", "버스 운전원%")`로 제안. 3. 사용자가 KSCO 코드 `8722` 선택. 4. 검색 쿼리: `DiseaseCase.joins(:ksco_codes).where(ksco_codes: { code: "8722" })`. 5. 선택된 KSCO 코드는 파라미터 `ksco_code[]`로 전달 (멀티 선택 가능). @@ -446,10 +472,10 @@ namespace :import do imported = 0 CSV.foreach(path, headers: true, encoding: "UTF-8") do |row| KscoCode.find_or_initialize_by(code: row["코드"].to_s.strip).update!( - level4_name: row["세분류"].to_s.strip, - level3_name: row["소분류"].to_s.strip, - level2_name: row["중분류"].to_s.strip, - level1_name: row["대분류"].to_s.strip, + name: row["세분류"].to_s.strip, + minor: row["소분류"].to_s.strip, + submajor: row["중분류"].to_s.strip, + major: row["대분류"].to_s.strip, job_examples: row["직업예시"].to_s.strip, exclusions: row["직업제외"].to_s.strip ) @@ -489,20 +515,21 @@ namespace :import do next end - # 1. 기존 DiseaseCase 속성 업데이트 + # 1. 기존 DiseaseCase 속성 업데이트 (숫자 필드는 반드시 to_i/to_d로 저장 — job_tenure_months/max_item_weight/ + # daily_total_weight는 wip/cerebras_prompts.rb 추출 스키마상 숫자이므로 문자열로 남기지 않는다) record.update!( job_name: row["직종명"].to_s.strip, - duty_description: row["담당_업무"].to_s.strip, + job_description: row["담당_업무"].to_s.strip, employment_type: row["고용_형태"].to_s.strip, work_type: row["근무_형태"].to_s.strip, - job_tenure: row["현_직종_총_종사기간"].to_s.strip, + job_tenure_months: row["현_직종_총_종사기간"].to_s.strip.presence&.to_i, weekly_work_hours: row["1주_평균_근무시간"].to_s.strip.presence&.to_d, daily_work_hours: row["1일_평균_근무시간"].to_s.strip.presence&.to_d, burden_body_part: row["부담_신체_부위"].to_s.strip, bad_posture: row["주요_부적절한_자세"].to_s.strip, heavy_lifting: row["중량물_취급_여부"].to_s.strip, - max_item_weight: row["취급_물품_최대_무게"].to_s.strip, - daily_total_weight: row["1일_취급_총_누적_중량"].to_s.strip, + max_item_weight: row["취급_물품_최대_무게"].to_s.strip.presence&.to_d, + daily_total_weight: row["1일_취급_총_누적_중량"].to_s.strip.presence&.to_d, other_harmful_factors: row["기타_유해요인_노출"].to_s.strip, work_relevance_eval: row["업무관련성_평가"].to_s.strip, aggravating_factors: row["업묵부담_가중요인_노출"].to_s.strip, @@ -511,24 +538,60 @@ namespace :import do application_type: row["신청서_종류"].to_s.strip ) - # 2. KSCO 매핑 저장 (JSON 파싱) + # 1-1. 최종_산재_인정_여부와 기존 result 비교 — 덮어쓰지 않고 불일치만 로그 + # (import_disease_cases.rake의 전역 상수 RESULT_MAP을 그대로 재사용) + extracted_result_raw = row["최종_산재_인정_여부"].to_s.strip + extracted_result = RESULT_MAP[extracted_result_raw] + if extracted_result && record.result.present? && record.result != extracted_result + warn "result 불일치 (case_no=#{case_no}): 기존=#{record.result} 추출=#{extracted_result}" + end + + # 2. KSCO 매핑 동기화 (JSON 파싱) — 이번 CSV의 ksco_codes_json을 "전체 목표 집합"으로 취급한다. + # 이 데이터는 추출 프롬프트/모델이 개선될 때마다 재추출·재임포트되므로, upsert만 하면 + # 이전에 붙었지만 이번에는 빠진 코드가 그대로 남아 ksco_code 필터가 오래된(false positive) + # 매핑까지 매칭하게 된다. 그래서 매번 "현재 JSON에 없는 매핑은 제거"까지 함께 수행한다. + # 주의: current_codes는 "이번 JSON에 언급된 코드 전체"여야 한다 — KscoCode 테이블에 아직 + # 없어서 skip한 코드도 포함해야 한다. 그렇지 않으면(= 실제로 attach에 성공한 코드만 넣으면) + # 참조 데이터(KscoCode)가 아직 불완전할 뿐인데 그 코드에 대한 기존 매핑까지 destroy_all로 + # 지워버려, 복구 가능한 참조 데이터 누락이 데이터 손실(및 ksco_code 필터의 false negative)로 이어진다. ksco_json = row["ksco_codes_json"].to_s.strip if ksco_json.present? - JSON.parse(ksco_json).each_with_index do |item, idx| - code = item["ksco_code"].to_s.strip - next if code.blank? - - ksco = KscoCode.find_by(code: code) - unless ksco - warn "KSCO 코드 없음: #{code} (case_no=#{case_no})" - next + parsed = + begin + JSON.parse(ksco_json) + rescue JSON::ParserError => e + warn "ksco_codes_json 파싱 실패, KSCO 매핑 동기화 건너뜀 (case_no=#{case_no}): #{e.message}" + nil + end + + if parsed + current_codes = [] + parsed.each do |item| + code = item["ksco_code"].to_s.strip + next if code.blank? + + current_codes << code + + ksco = KscoCode.find_by(code: code) + unless ksco + warn "KSCO 코드 없음: #{code} (case_no=#{case_no}) — 매핑은 유지, 참조 데이터 보완 후 재실행 필요" + next + end + + record.disease_case_ksco_codes.find_or_initialize_by(ksco_code_id: code).update!( + similarity: item["similarity"].to_f + ) end - record.disease_case_ksco_codes.find_or_initialize_by(ksco_code_id: code).update!( - similarity: item["similarity"].to_f - ) + # JSON 파싱에 성공했을 때만 "이번 JSON에 없는 매핑 제거"를 수행한다. + record.disease_case_ksco_codes.where.not(ksco_code_id: current_codes).destroy_all end end + # ksco_codes_json이 비어있거나 파싱 실패한 행은 삭제 동기화를 건너뛴다. 추출이 KSCO 필드를 + # 누락했는지, 형식이 깨졌는지, 아니면 정말 "매칭 없음"인지 CSV만으로는 구분할 수 없으므로, + # "비어있음 = 매핑 없음"으로 단정해 기존 매핑을 지우지 않는다 (안전한 기본값). + # 추출기가 "빈 값은 항상 KSCO 매칭 없음을 의미한다"를 보장하게 되면, 이 가드를 없애고 + # 빈 값에서도 destroy_all이 실행되도록 바꿀 수 있다. imported += 1 rescue => e @@ -547,7 +610,9 @@ namespace :import do end ``` -> **주의**: `ksco_codes_json`에 포함된 코드가 `KscoCode` 테이블에 없는 경우가 있을 수 있다 (CSV가 완전하지 않을 수 있음). `find_or_initialize_by` + `warn`로 처리. +> **주의**: `ksco_codes_json`에 포함된 코드가 `KscoCode` 테이블에 없는 경우가 있을 수 있다 (CSV가 완전하지 않을 수 있음). 이 경우 `warn`만 남기고 해당 코드의 조인 row는 만들지 않지만, `current_codes`에는 여전히 포함시켜 이후 `destroy_all`이 그 코드에 대한 **기존** 매핑까지 지우지 않도록 한다 — 참조 데이터 누락과 "이번 추출에서 실제로 빠짐"을 구분해야 재발급된 `KscoCode`로 재실행했을 때 데이터가 복구된다. +> **주의**: 위 코드가 참조하는 `RESULT_MAP`은 `lib/tasks/import_disease_cases.rake` 최상단에 정의된 전역 Ruby 상수를 그대로 재사용한다 (rake 파일이 로드되면 `namespace` 블록 밖의 최상위 상수는 전역으로 접근 가능). 별도로 다시 정의하지 않는다. +> **해결됨**: 이전 버전은 `ksco_codes_json`이 비어있어도 `destroy_all`을 무조건 실행해 해당 판정서의 모든 기존 KSCO 매핑을 지웠다. 위 코드는 `ksco_json.present?`이고 JSON 파싱이 성공했을 때만 `destroy_all`을 실행하도록 가드를 추가했다 — 빈 값/파싱 실패를 "매칭 없음"으로 단정하지 않고 기존 매핑을 보존한다. --- @@ -604,7 +669,7 @@ end 11. [ ] `DiseaseCasesController#main` 액션 추가 12. [ ] `main_search_params` strong parameters 정의 13. [ ] 기존 `index.html.erb`의 `form_with url:`을 `search_path`로 수정 -14. [ ] `app/mcp/tools/search_disease_cases_tool.rb` 업데이트: `job_name`, `duty_description`, `ksco_code`, `death_status` 파라미터 노출, ad-hoc 키워드 매칭을 새 구조화 필터로 대체/보완 +14. [ ] `app/mcp/tools/search_disease_cases_tool.rb` 업데이트: `job_name`, `job_description`, `ksco_code`, `death_status` 파라미터 노출, ad-hoc 키워드 매칭을 새 구조화 필터로 대체/보완 ### Phase 4: UI 개발 (2~3일) @@ -632,6 +697,7 @@ end | `ksco_codes_json` 내 코드가 `KscoCode` 테이블에 없음 | JOIN 시 누락 | rake task에서 `warn` 출력 후 무시. 추후 KSCO CSV 보완 | | FTS5 가상 테이블 2개 동시 유지 | 마이그레이션 복잡도 | 기존/신규 트리거를 별도 마이그레이션 파일로 분리. `db:migrate` 순서 명확히 | | 기존 검색 경로 변경 | 외부 링크/북마크 깨짐 | README, MCP 문서, `workercare.plan.md` 경로 동시 업데이트. `/`는 그대로 유효하므로 영향 최소 | +| `ksco_codes_json`이 비어있을 때 삭제 동기화를 건너뜀 (7.2절) | 추출 결과가 정말로 "매칭 없음"으로 바뀐 케이스에서 오래된 KSCO 매핑이 남아있을 수 있음 (false positive 가능성) | 의도된 안전한 기본값 — "비어있음"과 "필드 누락/파싱 실패"를 구분할 수 없는 한 삭제보다 보존을 택함. 추출기 계약이 "빈 값 = 매칭 없음 확정"을 보장하게 되면 이 가드를 제거하고 빈 값에서도 `destroy_all`을 실행하도록 전환 | --- @@ -642,6 +708,6 @@ end - **검색**: 기존 `/search` (legacy)는 `DiseaseCases::Searchable` + `disease_cases_fts` 그대로 유지. 새 메인 화면은 `DiseaseCases::MainSearchable` + `disease_cases_extracted_fts`로 분리 구현. - **데이터**: `import:ksco_codes` → `import:extracted_disease_cases` 순서로 실행. - **UI**: 직업/KSCO 중심의 필터 패널 + Full-text Hero 검색창. -- **MCP**: `app/mcp/tools/search_disease_cases_tool.rb`를 함께 업데이트하여, LLM 클라이언트가 새 `job_name`/`duty_description`/`ksco_codes`/`death_status` 컬럼을 필터로 사용할 수 있도록 한다. 기존의 `work_match?`/`symptom_match?` 같은 ad-hoc 키워드 매칭은 새 구조화 데이터로 대체하거나 보완한다. +- **MCP**: `app/mcp/tools/search_disease_cases_tool.rb`를 함께 업데이트하여, LLM 클라이언트가 새 `job_name`/`job_description`/`ksco_code[]`/`death_status` 파라미터를 필터로 사용할 수 있도록 한다 (`main_search_params`/`apply_main_filters`가 실제로 읽는 이름은 단수 `ksco_code`이며 배열로 받는다 — MCP 툴도 이 이름을 그대로 써야 하고, `ksco_codes`처럼 다른 이름을 쓰면 `main_search_params`가 값을 버리고 `apply_main_filters`도 읽지 않아 KSCO 필터가 조용히 무시된다). 기존의 `work_match?`/`symptom_match?` 같은 ad-hoc 키워드 매칭은 새 구조화 데이터로 대체하거나 보완한다. **다음 액션**: Phase 1 마이그레이션 승인 후 `rails db:migrate` 실행. From a4beb24a14ca9ba20866941f96389371522b7285 Mon Sep 17 00:00:00 2001 From: wagurano Date: Sat, 18 Jul 2026 13:00:00 +0900 Subject: [PATCH 02/24] =?UTF-8?q?=EC=A7=81=EC=97=85=EA=B3=BC=20=EC=8B=A0?= =?UTF-8?q?=EC=B2=B4=20=EB=B6=80=EC=9C=84=EB=A1=9C=20=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=ED=99=94=EB=A9=B4=EC=9D=84=20=EC=83=88?= =?UTF-8?q?=EB=A1=9C=20=EB=A7=8C=EB=93=A4=EA=B3=A0=20=EA=B3=BC=EA=B1=B0=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=20=EA=B2=80=EC=83=89=EC=9D=80=20/search?= =?UTF-8?q?=EB=A1=9C=20=EC=98=AE=EA=B9=81=EB=8B=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KSCO 직업분류와 추출 데이터를 연동한 새 메인 검색 화면을 추가합니다. 판정서에 직업·근무조건·신체부위 정보를 추가하고 이를 이용한 검색 화면을 만듭니다. --- app/controllers/disease_cases_controller.rb | 37 +++++- app/mcp/tools/search_disease_cases_tool.rb | 62 ++++++--- .../concerns/disease_cases/main_searchable.rb | 124 ++++++++++++++++++ app/models/disease_case.rb | 4 + app/models/disease_case_ksco_code.rb | 4 + app/models/ksco_code.rb | 6 + .../disease_cases/_main_case_list.html.erb | 17 +++ .../_main_search_results.html.erb | 20 +++ app/views/disease_cases/index.html.erb | 2 +- app/views/disease_cases/main.html.erb | 72 ++++++++++ .../disease_cases/main/_disease_case.html.erb | 20 +++ app/views/layouts/application.html.erb | 2 +- config/locales/en.yml | 4 + config/locales/ko.yml | 4 + config/locales/models/disease_case/en.yml | 18 +++ config/locales/models/disease_case/ko.yml | 18 +++ config/routes.rb | 5 +- .../20260824000001_create_ksco_codes.rb | 13 ++ ...24000002_create_disease_case_ksco_codes.rb | 13 ++ ..._add_extracted_columns_to_disease_cases.rb | 32 +++++ ...0004_create_disease_cases_extracted_fts.rb | 84 ++++++++++++ db/structure.sql | 83 +++++++++++- docs/workercare-search.plan.md | 13 ++ lib/tasks/import_extracted_disease_cases.rake | 111 ++++++++++++++++ lib/tasks/import_ksco_codes.rake | 23 ++++ .../disease_cases_controller_test.rb | 25 ++++ .../tools/search_disease_cases_tool_test.rb | 34 +++++ .../disease_cases/main_searchable_test.rb | 59 +++++++++ 28 files changed, 885 insertions(+), 24 deletions(-) create mode 100644 app/models/concerns/disease_cases/main_searchable.rb create mode 100644 app/models/disease_case_ksco_code.rb create mode 100644 app/models/ksco_code.rb create mode 100644 app/views/disease_cases/_main_case_list.html.erb create mode 100644 app/views/disease_cases/_main_search_results.html.erb create mode 100644 app/views/disease_cases/main.html.erb create mode 100644 app/views/disease_cases/main/_disease_case.html.erb create mode 100644 db/migrate/20260824000001_create_ksco_codes.rb create mode 100644 db/migrate/20260824000002_create_disease_case_ksco_codes.rb create mode 100644 db/migrate/20260824000003_add_extracted_columns_to_disease_cases.rb create mode 100644 db/migrate/20260824000004_create_disease_cases_extracted_fts.rb create mode 100644 lib/tasks/import_extracted_disease_cases.rake create mode 100644 lib/tasks/import_ksco_codes.rake create mode 100644 test/controllers/disease_cases_controller_test.rb create mode 100644 test/models/disease_cases/main_searchable_test.rb diff --git a/app/controllers/disease_cases_controller.rb b/app/controllers/disease_cases_controller.rb index 4619bb2..40b9864 100644 --- a/app/controllers/disease_cases_controller.rb +++ b/app/controllers/disease_cases_controller.rb @@ -1,8 +1,16 @@ class DiseaseCasesController < ApplicationController MAX_SEARCH_RESULTS = 500 + # 기존 /search (이전에는 root) def index - perform_search + perform_search(legacy: true) + end + + # 신규 / (메인 화면) + def main + perform_search(legacy: false) + @burden_body_part_options = burden_body_part_options + @application_type_options = application_type_options end def show @@ -11,14 +19,28 @@ def show private - def perform_search - @scope, @fallback = DiseaseCase.search(search_params) + def perform_search(legacy:) + @scope, @fallback = legacy ? DiseaseCase.search(search_params) : DiseaseCase.main_search(main_search_params) @pagy, @cases = paginate(@scope) + @cases = @cases.includes(:ksco_codes) unless legacy @metadata = build_metadata log_search_event end + # burden_body_part는 파이프(|) 구분 다중값 텍스트이므로, 체크박스 후보 목록은 저장된 값을 + # split해서 만든다 (사전 정의된 enum이 아님 — 3.2절 참고). + def burden_body_part_options + DiseaseCase.where.not(burden_body_part: [ nil, "" ]) + .distinct.pluck(:burden_body_part) + .flat_map { |v| v.split("|") } + .map(&:strip).reject(&:blank?).uniq.sort + end + + def application_type_options + DiseaseCase.where.not(application_type: [ nil, "" ]).distinct.order(:application_type).pluck(:application_type) + end + def paginate(scope) pagy(scope, items: 12, max_items: MAX_SEARCH_RESULTS) end @@ -37,6 +59,15 @@ def search_params params.permit(:q, :result, :year, :decided_on_from, :decided_on_to, :sort, :commit, :search, search_in: [], disease_category: [], body_part: []) end + def main_search_params + params.permit( + :q, :job_name, :job_description, + :death_status, :application_type, :employment_type, :work_type, + :work_relevance_eval, :sort, :commit, :search, + burden_body_part: [], ksco_code: [] + ) + end + def log_search_event Rails.logger.info({ event: "search", diff --git a/app/mcp/tools/search_disease_cases_tool.rb b/app/mcp/tools/search_disease_cases_tool.rb index aae66aa..b0a2dbb 100644 --- a/app/mcp/tools/search_disease_cases_tool.rb +++ b/app/mcp/tools/search_disease_cases_tool.rb @@ -43,9 +43,30 @@ class SearchDiseaseCasesTool < ApplicationMCPTool required: false, default: 10 + property :job_name, + type: "string", + description: "Filter by job title/occupation (partial match), e.g. '버스 운전원'", + required: false + + property :job_description, + type: "string", + description: "Filter by job duty description (partial match)", + required: false + + property :death_status, + type: "string", + description: "Filter by whether the case involved a death. Allowed: Y, N", + required: false + + collection :ksco_code, + type: "string", + description: "Filter by KSCO (Korean Standard Classification of Occupations) code(s), e.g. 8722", + required: false + def perform search_params = build_search_params scope, fallback = DiseaseCase.search(search_params) + scope = DiseaseCase.apply_main_filters(scope, main_filter_params) total_count = scope.count if total_count.zero? @@ -109,8 +130,20 @@ def parse_date(value) raise ArgumentError, "날짜 형식이 올바르지 않습니다. YYYY-MM-DD 형식으로 입력해주세요." end + def main_filter_params + params = {} + params[:job_name] = job_name if job_name.present? + params[:job_description] = job_description if job_description.present? + params[:death_status] = death_status if death_status.present? + params[:ksco_code] = Array(ksco_code).reject(&:blank?) if ksco_code.present? + params + end + def build_statistics(scope) - counts = scope.group(:result).count + # scope에 걸려있는 ORDER BY bm25(...)는 GROUP BY 집계와 함께 쓰면 SQLite가 + # "unable to use function bm25 in the requested context"를 던진다. 집계 결과는 정렬과 + # 무관하므로 order를 제거하고 집계한다. + counts = scope.reorder(nil).group(:result).count approved = counts["approved"] || 0 rejected = counts["rejected"] || 0 @@ -159,6 +192,8 @@ def build_cases(scope) result: c.result, result_label: result_label(c.result), year: c.year, + job_name: c.job_name, + death_status: c.death_status, summary: truncate(c.applicant_claim, 300), key_facts: truncate(c.recognized_facts, 300), decision_excerpt: truncate(c.committee_decision, 300), @@ -176,12 +211,16 @@ def result_label(result) }[result] || result end + # job_name/job_description/death_status/ksco_code는 apply_main_filters로 이미 WHERE 조건에 + # 반영되어 있으므로, 여기서는 "그 필터가 지정되었는가"만 확인하면 된다 (반환된 case는 이미 매칭된 + # 것이 보장됨). 자유 텍스트 키워드를 work_keywords/symptom_keywords 사전과 비교하던 기존 + # work_match?/symptom_match? ad-hoc 로직은 구조화 필터로 대체한다. def build_match_reason(disease_case) reasons = [] reasons << "동일 신체부위" if body_part_match?(disease_case) - reasons << "유사 업무" if work_match?(disease_case) - reasons << "유사 증상" if symptom_match?(disease_case) reasons << "동일 질병" if disease_match?(disease_case) + reasons << "동일 직종" if job_name.present? + reasons << "동일 사망 여부" if death_status.present? reasons.map { |r| { reason: r } } end @@ -189,18 +228,6 @@ def body_part_match?(disease_case) Array(body_part).any? { |bp| disease_case.body_part == bp } end - def work_match?(disease_case) - query_tokens = q.to_s.split(" ").map { |t| t.gsub(/은|는|이|가|을|를|의$/, "") } - work_keywords = %w[사무실 컴퓨터 공장 건설 운전 반복 야간 교대] - query_tokens.any? { |token| work_keywords.any? { |kw| token.include?(kw) } } - end - - def symptom_match?(disease_case) - query_tokens = q.to_s.split(" ").map { |t| t.gsub(/은|는|이|가|을|를|의$/, "") } - symptom_keywords = %w[아픔 통증 저림 불편 마비 어지러움] - query_tokens.any? { |token| symptom_keywords.any? { |kw| token.include?(kw) } } - end - def disease_match?(disease_case) Array(disease_category).any? { |dc| disease_case.disease_category == dc } end @@ -211,17 +238,20 @@ def calculate_confidence(cases_data, fallback) body_part_matches = cases_data.count { |c| c[:match_reason].any? { |r| r[:reason] == "동일 신체부위" } } disease_matches = cases_data.count { |c| c[:match_reason].any? { |r| r[:reason] == "동일 질병" } } + job_name_matches = cases_data.count { |c| c[:match_reason].any? { |r| r[:reason] == "동일 직종" } } score = 0.5 score += 0.2 if body_part_matches > 0 score += 0.2 if disease_matches > 0 + score += 0.1 if job_name_matches > 0 score -= 0.1 if fallback - score = [[score, 0.0].max, 1.0].min + score = [ [ score, 0.0 ].max, 1.0 ].min reason_parts = [] reason_parts << "유사 사례 #{total}건" reason_parts << "동일 신체부위 #{body_part_matches}건" if body_part_matches > 0 reason_parts << "동일 질병분류 #{disease_matches}건" if disease_matches > 0 + reason_parts << "동일 직종 #{job_name_matches}건" if job_name_matches > 0 reason_parts << "(substring fallback 사용)" if fallback { score: score.round(2), reason: reason_parts.join(", ") } diff --git a/app/models/concerns/disease_cases/main_searchable.rb b/app/models/concerns/disease_cases/main_searchable.rb new file mode 100644 index 0000000..7e4a6a6 --- /dev/null +++ b/app/models/concerns/disease_cases/main_searchable.rb @@ -0,0 +1,124 @@ +module DiseaseCases + module MainSearchable + extend ActiveSupport::Concern + + MAIN_SEARCHABLE_COLUMNS = %w[ + job_name + job_description + main_reasoning + other_harmful_factors + aggravating_factors + ].freeze + + class_methods do + def main_search(params = {}) + raw_query = params[:q].to_s.strip + fts_q = build_main_fts_query(raw_query) + tokens = normalize_query(raw_query).split(" ").first(5) + + # 1. Full-text scope (새 FTS5 가상 테이블 사용) + if fts_q.present? + scope = main_fulltext(fts_q) + if scope.empty? + scope = substring_job_fallback(tokens) + fallback = true + else + fallback = false + end + else + scope = all + fallback = false + end + + # 2. Structured filters + scope = apply_main_filters(scope, params) + + # 3. Sort + scope = apply_main_sort(scope, fts_q, fallback, params[:sort]) + + [ scope, fallback ] + end + + # MCP search_disease_cases_tool처럼 legacy DiseaseCase.search 결과에 새 구조화 필터를 + # 추가로 적용하고 싶은 호출부를 위해 public으로 유지한다 (main_search 내부에서도 재사용). + def apply_main_filters(scope, params) + # 직업/하는일: 부분 일치 (LIKE) — exact match는 표현 통일성이 없는 추출 데이터에서 0건 위험 + if params[:job_name].present? + safe = sanitize_sql_like(params[:job_name]) + scope = scope.where("job_name LIKE ?", "%#{safe}%") + end + + if params[:job_description].present? + safe = sanitize_sql_like(params[:job_description]) + scope = scope.where("job_description LIKE ?", "%#{safe}%") + end + + # 부담_신체_부위는 파이프(|)로 구분된 다중값 텍스트다(예: "목|상체|하체"). + # exact match나 단순 LIKE '%값%'은 "목"이 "손목"/"발목"/"뒷목"까지 잘못 매칭하므로, + # 파이프 경계를 인식하는 매칭(정확히 일치/맨앞/맨뒤/중간)으로 처리한다. + if params[:burden_body_part].present? + values = Array(params[:burden_body_part]).reject(&:blank?) + if values.any? + conditions = values.map { + "(burden_body_part = ? OR burden_body_part LIKE ? OR burden_body_part LIKE ? OR burden_body_part LIKE ?)" + }.join(" OR ") + binds = values.flat_map { |v| + safe = sanitize_sql_like(v) + [ v, "#{safe}|%", "%|#{safe}", "%|#{safe}|%" ] + } + scope = scope.where(conditions, *binds) + end + end + + scope = scope.where(death_status: params[:death_status]) if params[:death_status].present? + scope = scope.where(application_type: params[:application_type]) if params[:application_type].present? + scope = scope.where(employment_type: params[:employment_type]) if params[:employment_type].present? + scope = scope.where(work_type: params[:work_type]) if params[:work_type].present? + scope = scope.where(work_relevance_eval: params[:work_relevance_eval]) if params[:work_relevance_eval].present? + + # KSCO 코드 필터 (JOIN) — `.distinct` 필수: 하나의 판정서가 여러 KSCO 코드와 매핑될 수 있으므로 + # 멀티 선택 시 동일 판정서가 중복 행으로 노출되고 pagy 카운트가 부풀려지는 것을 방지 + if params[:ksco_code].present? + codes = Array(params[:ksco_code]).reject(&:blank?) + scope = scope.joins(:ksco_codes).where(ksco_codes: { code: codes }).distinct if codes.any? + end + + scope + end + + private + + def apply_main_sort(scope, fts_q, fallback, sort_param) + if fts_q.present? && !fallback && sort_param != "recent" + scope.order(Arel.sql("bm25(disease_cases_extracted_fts, 1.0, 1.0, 2.0, 0.5, 0.5)")) + else + scope.order(year: :desc) + end + end + + def main_fulltext(query) + joins("JOIN disease_cases_extracted_fts ON disease_cases_extracted_fts.rowid = disease_cases.id") + .where("disease_cases_extracted_fts MATCH ?", query) + end + + def build_main_fts_query(raw) + query = normalize_query(raw) + return nil if query.blank? + + tokens = query.split(" ").first(5) + .map { |token| token.gsub(DiseaseCases::Searchable::KOREAN_PARTICLES, "") } + .reject(&:blank?) + return nil if tokens.empty? + + tokens.map { |token| "\"#{token.gsub('"', '""')}\"" }.join(" AND ") + end + + def substring_job_fallback(tokens) + safe_tokens = tokens.first(2).map { |token| sanitize_sql_like(token) } + conditions = safe_tokens.map { "job_name LIKE ? OR job_description LIKE ?" }.join(" AND ") + binds = safe_tokens.flat_map { |token| [ "%#{token}%", "%#{token}%" ] } + where(conditions, *binds) + end + end + end +end diff --git a/app/models/disease_case.rb b/app/models/disease_case.rb index d028448..d5f89cc 100644 --- a/app/models/disease_case.rb +++ b/app/models/disease_case.rb @@ -1,6 +1,10 @@ class DiseaseCase < ApplicationRecord include HumanEnumerable include DiseaseCases::Searchable + include DiseaseCases::MainSearchable + + has_many :disease_case_ksco_codes + has_many :ksco_codes, through: :disease_case_ksco_codes enum :result, { approved: "approved", diff --git a/app/models/disease_case_ksco_code.rb b/app/models/disease_case_ksco_code.rb new file mode 100644 index 0000000..2f0662d --- /dev/null +++ b/app/models/disease_case_ksco_code.rb @@ -0,0 +1,4 @@ +class DiseaseCaseKscoCode < ApplicationRecord + belongs_to :disease_case + belongs_to :ksco_code, primary_key: "code", foreign_key: "ksco_code_id" +end diff --git a/app/models/ksco_code.rb b/app/models/ksco_code.rb new file mode 100644 index 0000000..d772ced --- /dev/null +++ b/app/models/ksco_code.rb @@ -0,0 +1,6 @@ +class KscoCode < ApplicationRecord + self.primary_key = "code" + + has_many :disease_case_ksco_codes, primary_key: "code", foreign_key: "ksco_code_id" + has_many :disease_cases, through: :disease_case_ksco_codes +end diff --git a/app/views/disease_cases/_main_case_list.html.erb b/app/views/disease_cases/_main_case_list.html.erb new file mode 100644 index 0000000..3b8c6f5 --- /dev/null +++ b/app/views/disease_cases/_main_case_list.html.erb @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + <%= render partial: "disease_cases/main/disease_case", collection: cases %> + +
<%= DiseaseCase.human_attribute_name(:job_name) %><%= DiseaseCase.human_attribute_name(:job_description) %><%= DiseaseCase.human_attribute_name(:application_type) %><%= DiseaseCase.human_attribute_name(:death_status) %><%= DiseaseCase.human_attribute_name(:disease_name) %><%= DiseaseCase.human_attribute_name(:result) %><%= DiseaseCase.human_attribute_name(:year) %>KSCO
diff --git a/app/views/disease_cases/_main_search_results.html.erb b/app/views/disease_cases/_main_search_results.html.erb new file mode 100644 index 0000000..ab031c4 --- /dev/null +++ b/app/views/disease_cases/_main_search_results.html.erb @@ -0,0 +1,20 @@ +
+ <% if @metadata[:over_cap] %> +

<%= t("search.over_cap") %>

+ <% elsif @metadata[:total_count] %> +

<%= @metadata[:total_count] %> <%= t("search.results") %>

+ <% end %> + <% if @fallback %> +
<%= t("search.fallback_notice") %>
+ <% end %> + <% if @cases.empty? %> +

<%= t("search.no_results") %>

+ <% else %> +
+ <%= render "main_case_list", cases: @cases %> +
+
+ <%== @pagy.series_nav %> +
+ <% end %> +
diff --git a/app/views/disease_cases/index.html.erb b/app/views/disease_cases/index.html.erb index e11a765..e3901de 100644 --- a/app/views/disease_cases/index.html.erb +++ b/app/views/disease_cases/index.html.erb @@ -1,7 +1,7 @@ <% content_for :title, t("search.title") %>

<%= t("search.advanced") %>

- <%= form_with url: root_path, method: :get, local: true, data: { controller: "search-form" } do |form| %> + <%= form_with url: search_path, method: :get, local: true, data: { controller: "search-form" } do |form| %>
<%= form.search_field :q, value: params[:q], placeholder: t("search.placeholder") %> <%= form.submit t("helpers.submit.search"), name: "search", data: { "search-form-target": "button", "loading-text": t("search.loading") } %> diff --git a/app/views/disease_cases/main.html.erb b/app/views/disease_cases/main.html.erb new file mode 100644 index 0000000..86dcac8 --- /dev/null +++ b/app/views/disease_cases/main.html.erb @@ -0,0 +1,72 @@ +<% content_for :title, t("search.main_title") %> +
+

<%= t("search.main_title") %>

+ <%= form_with url: root_path, method: :get, local: true, data: { controller: "search-form" } do |form| %> +
+ <%= form.search_field :q, value: params[:q], placeholder: t("search.main_placeholder") %> + <%= form.submit t("helpers.submit.search"), name: "search", data: { "search-form-target": "button", "loading-text": t("search.loading") } %> +
+
+
+

<%= t("search.loading") %>

+
+ +
+
+ <%= form.label :job_name, DiseaseCase.human_attribute_name(:job_name) %> + <%= form.text_field :job_name, value: params[:job_name] %> +
+
+ <%= form.label :job_description, DiseaseCase.human_attribute_name(:job_description) %> + <%= form.text_field :job_description, value: params[:job_description] %> +
+
+ +
+ + <%= DiseaseCase.human_attribute_name(:burden_body_part) %> + <%= link_to t("search.select_all"), "#", data: { action: "click->checkbox-group#selectAll" } %> + <%= link_to t("search.deselect_all"), "#", data: { action: "click->checkbox-group#deselectAll" } %> + +
+ <% @burden_body_part_options.each do |value| %> + + <% end %> +
+
+ +
+
+ <%= form.label :application_type, DiseaseCase.human_attribute_name(:application_type) %> + <%= form.select :application_type, + [ [ "— #{t('search.all_results')} —", "" ] ] + @application_type_options.map { |v| [ v, v ] }, + selected: params[:application_type] %> +
+
+ <%= check_box_tag :death_status, "Y", params[:death_status] == "Y" %> + <%= label_tag :death_status, t("search.death_only") %> +
+
+ +
+ <%= t("search.sort_label") %> +
+ + +
+
+ <% end %> + +

<%= link_to t("search.go_to_advanced_search"), search_path %>

+ + <%= render "main_search_results" %> +
diff --git a/app/views/disease_cases/main/_disease_case.html.erb b/app/views/disease_cases/main/_disease_case.html.erb new file mode 100644 index 0000000..8aac240 --- /dev/null +++ b/app/views/disease_cases/main/_disease_case.html.erb @@ -0,0 +1,20 @@ + + <%= disease_case.job_name.presence || "-" %> + <%= truncate(disease_case.job_description, length: 40) %> + <%= disease_case.application_type.presence || "-" %> + + <% if disease_case.death_status == "Y" %> + <%= t("search.death_only") %> + <% end %> + + + <%= link_to disease_case.disease_name, disease_case_path(disease_case) %> + + <%= disease_case.human_attribute_enum(:result) %> + <%= disease_case.year %>년 + + <% disease_case.ksco_codes.each do |ksco| %> + <%= ksco.name %> + <% end %> + + diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index ad10a53..ede54b7 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -24,7 +24,7 @@
  • - <%= link_to "검색", root_path %> + <%= link_to "검색", search_path %>
  • <%= link_to "소개", about_path %> diff --git a/config/locales/en.yml b/config/locales/en.yml index acde9eb..6a1b4bd 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -26,3 +26,7 @@ en: sort_recent: Recent sort_relevance: Relevance title: Occupational Disease Search + main_title: Find Cases by Job & Body Part + main_placeholder: Search by job, duties, reasoning... + death_only: Fatal cases only + go_to_advanced_search: Advanced Search (legacy) diff --git a/config/locales/ko.yml b/config/locales/ko.yml index 524ddb8..85ab74e 100644 --- a/config/locales/ko.yml +++ b/config/locales/ko.yml @@ -26,3 +26,7 @@ ko: sort_recent: 최신순 sort_relevance: 관련도순 title: 업무상질병 판정서 검색 + main_title: 직업·신체 부위로 판정서 찾기 + main_placeholder: 직업, 업무, 판단 근거로 검색... + death_only: 사망 사례만 보기 + go_to_advanced_search: 상세 검색 (기존 화면) diff --git a/config/locales/models/disease_case/en.yml b/config/locales/models/disease_case/en.yml index 9ad4e53..92c1ff7 100644 --- a/config/locales/models/disease_case/en.yml +++ b/config/locales/models/disease_case/en.yml @@ -19,6 +19,24 @@ en: result: Result statement: Order year: Year + job_name: Job Title + job_description: Job Description + employment_type: Employment Type + work_type: Work Type + job_tenure_months: Tenure + weekly_work_hours: Weekly Work Hours + daily_work_hours: Daily Work Hours + burden_body_part: Burdened Body Part + bad_posture: Poor Posture + heavy_lifting: Heavy Lifting + max_item_weight: Max Item Weight + daily_total_weight: Daily Total Weight + other_harmful_factors: Other Harmful Factors + work_relevance_eval: Work Relevance + aggravating_factors: Aggravating Factors + main_reasoning: Main Reasoning + death_status: Death + application_type: Application Type disease_case/result_enum: approved: "Approved" rejected: "Rejected" diff --git a/config/locales/models/disease_case/ko.yml b/config/locales/models/disease_case/ko.yml index d0820fe..77d8028 100644 --- a/config/locales/models/disease_case/ko.yml +++ b/config/locales/models/disease_case/ko.yml @@ -19,6 +19,24 @@ ko: result: 심의결과 statement: 주문 year: 심의연도 + job_name: 직종명 + job_description: 담당 업무 + employment_type: 고용 형태 + work_type: 근무 형태 + job_tenure_months: 종사 기간 + weekly_work_hours: 1주 평균 근무시간 + daily_work_hours: 1일 평균 근무시간 + burden_body_part: 부담 신체 부위 + bad_posture: 부적절한 자세 + heavy_lifting: 중량물 취급 여부 + max_item_weight: 취급 물품 최대 무게 + daily_total_weight: 1일 취급 총 누적 중량 + other_harmful_factors: 기타 유해요인 + work_relevance_eval: 업무관련성 평가 + aggravating_factors: 업무부담 가중요인 + main_reasoning: 판단 주요 근거 + death_status: 사망 여부 + application_type: 신청서 종류 disease_case/result_enum: approved: 인정 rejected: 불인정 diff --git a/config/routes.rb b/config/routes.rb index 22119fb..50adcda 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,8 +1,9 @@ Rails.application.routes.draw do - root "disease_cases#index" + root "disease_cases#main" + get "/search", to: "disease_cases#index", as: :search get "/about", to: "pages#about", as: :about - resources :disease_cases, param: :case_no, only: [ :index, :show ] + resources :disease_cases, param: :case_no, only: [ :show ] get "up" => "rails/health#show", :as => :rails_health_check end diff --git a/db/migrate/20260824000001_create_ksco_codes.rb b/db/migrate/20260824000001_create_ksco_codes.rb new file mode 100644 index 0000000..b98138f --- /dev/null +++ b/db/migrate/20260824000001_create_ksco_codes.rb @@ -0,0 +1,13 @@ +class CreateKscoCodes < ActiveRecord::Migration[8.1] + def change + create_table :ksco_codes, id: false do |t| + t.string :code, primary_key: true + t.string :name + t.string :minor + t.string :submajor + t.string :major + t.text :job_examples + t.text :exclusions + end + end +end diff --git a/db/migrate/20260824000002_create_disease_case_ksco_codes.rb b/db/migrate/20260824000002_create_disease_case_ksco_codes.rb new file mode 100644 index 0000000..a1c8d33 --- /dev/null +++ b/db/migrate/20260824000002_create_disease_case_ksco_codes.rb @@ -0,0 +1,13 @@ +class CreateDiseaseCaseKscoCodes < ActiveRecord::Migration[8.1] + def change + create_table :disease_case_ksco_codes do |t| + t.references :disease_case, null: false, foreign_key: true + t.string :ksco_code_id, null: false + t.float :similarity + end + + add_index :disease_case_ksco_codes, [ :disease_case_id, :ksco_code_id ], + unique: true, name: "index_disease_case_ksco_codes_on_case_and_code" + add_index :disease_case_ksco_codes, :ksco_code_id + end +end diff --git a/db/migrate/20260824000003_add_extracted_columns_to_disease_cases.rb b/db/migrate/20260824000003_add_extracted_columns_to_disease_cases.rb new file mode 100644 index 0000000..006a3fe --- /dev/null +++ b/db/migrate/20260824000003_add_extracted_columns_to_disease_cases.rb @@ -0,0 +1,32 @@ +class AddExtractedColumnsToDiseaseCases < ActiveRecord::Migration[8.1] + def change + change_table :disease_cases, bulk: true do |t| + t.string :job_name + t.text :job_description + t.string :employment_type + t.string :work_type + t.integer :job_tenure_months + t.decimal :weekly_work_hours + t.decimal :daily_work_hours + t.text :burden_body_part + t.text :bad_posture + t.string :heavy_lifting + t.decimal :max_item_weight + t.decimal :daily_total_weight + t.text :other_harmful_factors + t.string :work_relevance_eval + t.text :aggravating_factors + t.text :main_reasoning + t.string :death_status + t.string :application_type + + t.index :job_name + t.index :employment_type + t.index :work_type + t.index :heavy_lifting + t.index :work_relevance_eval + t.index :death_status + t.index :application_type + end + end +end diff --git a/db/migrate/20260824000004_create_disease_cases_extracted_fts.rb b/db/migrate/20260824000004_create_disease_cases_extracted_fts.rb new file mode 100644 index 0000000..fdd6573 --- /dev/null +++ b/db/migrate/20260824000004_create_disease_cases_extracted_fts.rb @@ -0,0 +1,84 @@ +class CreateDiseaseCasesExtractedFts < ActiveRecord::Migration[8.1] + def up + execute <<~SQL + CREATE VIRTUAL TABLE disease_cases_extracted_fts + USING fts5( + job_name, + job_description, + main_reasoning, + other_harmful_factors, + aggravating_factors, + content='disease_cases', + content_rowid='id', + tokenize='unicode61' + ); + SQL + + # 이 테이블은 disease_cases에 이미 존재하는 레코드들 위에 나중에 추가되는 external content + # FTS5 테이블이다. CREATE VIRTUAL TABLE만으로는 shadow index가 비어있는 채로 남는데, 그 상태에서 + # 기존 레코드에 UPDATE가 발생하면 아래 update/delete 트리거가 "색인된 적 없는 rowid"에 대해 + # delete pseudo-row를 시도하게 되어 FTS5 내부 색인이 깨진다(SQLite3::CorruptException: + # database disk image is malformed). 트리거를 만들기 전에 먼저 현재 상태로 rebuild해 + # 모든 기존 rowid를 색인에 채워 넣어야 이후 update/delete 트리거가 안전하게 동작한다. + execute <<~SQL + INSERT INTO disease_cases_extracted_fts(disease_cases_extracted_fts) VALUES('rebuild'); + SQL + + execute <<~SQL + CREATE TRIGGER disease_cases_extracted_fts_insert + AFTER INSERT ON disease_cases BEGIN + INSERT INTO disease_cases_extracted_fts( + rowid, job_name, job_description, main_reasoning, + other_harmful_factors, aggravating_factors + ) VALUES ( + new.id, new.job_name, new.job_description, new.main_reasoning, + new.other_harmful_factors, new.aggravating_factors + ); + END; + SQL + + execute <<~SQL + CREATE TRIGGER disease_cases_extracted_fts_delete + AFTER DELETE ON disease_cases BEGIN + INSERT INTO disease_cases_extracted_fts( + disease_cases_extracted_fts, + rowid, job_name, job_description, main_reasoning, + other_harmful_factors, aggravating_factors + ) VALUES ( + 'delete', + old.id, old.job_name, old.job_description, old.main_reasoning, + old.other_harmful_factors, old.aggravating_factors + ); + END; + SQL + + execute <<~SQL + CREATE TRIGGER disease_cases_extracted_fts_update + AFTER UPDATE ON disease_cases BEGIN + INSERT INTO disease_cases_extracted_fts( + disease_cases_extracted_fts, + rowid, job_name, job_description, main_reasoning, + other_harmful_factors, aggravating_factors + ) VALUES ( + 'delete', + old.id, old.job_name, old.job_description, old.main_reasoning, + old.other_harmful_factors, old.aggravating_factors + ); + INSERT INTO disease_cases_extracted_fts( + rowid, job_name, job_description, main_reasoning, + other_harmful_factors, aggravating_factors + ) VALUES ( + new.id, new.job_name, new.job_description, new.main_reasoning, + new.other_harmful_factors, new.aggravating_factors + ); + END; + SQL + end + + def down + execute "DROP TRIGGER IF EXISTS disease_cases_extracted_fts_update;" + execute "DROP TRIGGER IF EXISTS disease_cases_extracted_fts_delete;" + execute "DROP TRIGGER IF EXISTS disease_cases_extracted_fts_insert;" + execute "DROP TABLE IF EXISTS disease_cases_extracted_fts;" + end +end diff --git a/db/structure.sql b/db/structure.sql index 4c35a1c..67708b5 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -1,6 +1,6 @@ CREATE TABLE IF NOT EXISTS "schema_migrations" ("version" varchar NOT NULL PRIMARY KEY); CREATE TABLE IF NOT EXISTS "ar_internal_metadata" ("key" varchar NOT NULL PRIMARY KEY, "value" varchar, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL); -CREATE TABLE IF NOT EXISTS "disease_cases" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "case_no" varchar NOT NULL, "disease_name" varchar, "result" varchar, "year" integer, "disease_category" varchar, "body_part" varchar, "link" varchar, "statement" text, "claim_purpose" text, "application_content" text, "applicant_claim" text, "medical_records" text, "recognized_facts" text, "related_laws" text, "committee_decision" text, "decided_on" date, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL); +CREATE TABLE IF NOT EXISTS "disease_cases" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "case_no" varchar NOT NULL, "disease_name" varchar, "result" varchar, "year" integer, "disease_category" varchar, "body_part" varchar, "link" varchar, "statement" text, "claim_purpose" text, "application_content" text, "applicant_claim" text, "medical_records" text, "recognized_facts" text, "related_laws" text, "committee_decision" text, "decided_on" date, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, "job_name" varchar /*application='Workercare'*/, "job_description" text /*application='Workercare'*/, "employment_type" varchar /*application='Workercare'*/, "work_type" varchar /*application='Workercare'*/, "job_tenure_months" integer /*application='Workercare'*/, "weekly_work_hours" decimal /*application='Workercare'*/, "daily_work_hours" decimal /*application='Workercare'*/, "burden_body_part" text /*application='Workercare'*/, "bad_posture" text /*application='Workercare'*/, "heavy_lifting" varchar /*application='Workercare'*/, "max_item_weight" decimal /*application='Workercare'*/, "daily_total_weight" decimal /*application='Workercare'*/, "other_harmful_factors" text /*application='Workercare'*/, "work_relevance_eval" varchar /*application='Workercare'*/, "aggravating_factors" text /*application='Workercare'*/, "main_reasoning" text /*application='Workercare'*/, "death_status" varchar /*application='Workercare'*/, "application_type" varchar /*application='Workercare'*/); CREATE UNIQUE INDEX "index_disease_cases_on_case_no" ON "disease_cases" ("case_no") /*application='Workercare'*/; CREATE INDEX "index_disease_cases_on_result" ON "disease_cases" ("result") /*application='Workercare'*/; CREATE INDEX "index_disease_cases_on_year" ON "disease_cases" ("year") /*application='Workercare'*/; @@ -20,6 +20,10 @@ USING fts5( tokenize='unicode61' ) /* disease_cases_fts(application_content,applicant_claim,medical_records,recognized_facts,committee_decision) */; +CREATE TABLE IF NOT EXISTS 'disease_cases_fts_data'(id INTEGER PRIMARY KEY, block BLOB); +CREATE TABLE IF NOT EXISTS 'disease_cases_fts_idx'(segid, term, pgno, PRIMARY KEY(segid, term)) WITHOUT ROWID; +CREATE TABLE IF NOT EXISTS 'disease_cases_fts_docsize'(id INTEGER PRIMARY KEY, sz BLOB); +CREATE TABLE IF NOT EXISTS 'disease_cases_fts_config'(k PRIMARY KEY, v) WITHOUT ROWID; CREATE TRIGGER disease_cases_fts_insert AFTER INSERT ON disease_cases BEGIN INSERT INTO disease_cases_fts( @@ -115,7 +119,83 @@ CREATE TABLE IF NOT EXISTS "action_mcp_sessions" ("id" varchar NOT NULL PRIMARY CREATE TABLE IF NOT EXISTS "solid_mcp_messages" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "session_id" varchar(36) NOT NULL, "event_type" varchar(50) NOT NULL, "data" text, "created_at" datetime(6) NOT NULL, "delivered_at" datetime(6)); CREATE INDEX "idx_solid_mcp_messages_on_session_and_id" ON "solid_mcp_messages" ("session_id", "id") /*application='Workercare'*/; CREATE INDEX "idx_solid_mcp_messages_on_delivered_and_created" ON "solid_mcp_messages" ("delivered_at", "created_at") /*application='Workercare'*/; +CREATE TABLE IF NOT EXISTS "ksco_codes" ("code" varchar NOT NULL PRIMARY KEY, "name" varchar, "minor" varchar, "submajor" varchar, "major" varchar, "job_examples" text, "exclusions" text); +CREATE TABLE IF NOT EXISTS "disease_case_ksco_codes" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "disease_case_id" integer NOT NULL, "ksco_code_id" varchar NOT NULL, "similarity" float, CONSTRAINT "fk_rails_5b67623f65" +FOREIGN KEY ("disease_case_id") + REFERENCES "disease_cases" ("id") +); +CREATE INDEX "index_disease_case_ksco_codes_on_disease_case_id" ON "disease_case_ksco_codes" ("disease_case_id") /*application='Workercare'*/; +CREATE UNIQUE INDEX "index_disease_case_ksco_codes_on_case_and_code" ON "disease_case_ksco_codes" ("disease_case_id", "ksco_code_id") /*application='Workercare'*/; +CREATE INDEX "index_disease_case_ksco_codes_on_ksco_code_id" ON "disease_case_ksco_codes" ("ksco_code_id") /*application='Workercare'*/; +CREATE INDEX "index_disease_cases_on_job_name" ON "disease_cases" ("job_name") /*application='Workercare'*/; +CREATE INDEX "index_disease_cases_on_employment_type" ON "disease_cases" ("employment_type") /*application='Workercare'*/; +CREATE INDEX "index_disease_cases_on_work_type" ON "disease_cases" ("work_type") /*application='Workercare'*/; +CREATE INDEX "index_disease_cases_on_heavy_lifting" ON "disease_cases" ("heavy_lifting") /*application='Workercare'*/; +CREATE INDEX "index_disease_cases_on_work_relevance_eval" ON "disease_cases" ("work_relevance_eval") /*application='Workercare'*/; +CREATE INDEX "index_disease_cases_on_death_status" ON "disease_cases" ("death_status") /*application='Workercare'*/; +CREATE INDEX "index_disease_cases_on_application_type" ON "disease_cases" ("application_type") /*application='Workercare'*/; +CREATE VIRTUAL TABLE disease_cases_extracted_fts +USING fts5( + job_name, + job_description, + main_reasoning, + other_harmful_factors, + aggravating_factors, + content='disease_cases', + content_rowid='id', + tokenize='unicode61' +) +/* disease_cases_extracted_fts(job_name,job_description,main_reasoning,other_harmful_factors,aggravating_factors) */; +CREATE TABLE IF NOT EXISTS 'disease_cases_extracted_fts_data'(id INTEGER PRIMARY KEY, block BLOB); +CREATE TABLE IF NOT EXISTS 'disease_cases_extracted_fts_idx'(segid, term, pgno, PRIMARY KEY(segid, term)) WITHOUT ROWID; +CREATE TABLE IF NOT EXISTS 'disease_cases_extracted_fts_docsize'(id INTEGER PRIMARY KEY, sz BLOB); +CREATE TABLE IF NOT EXISTS 'disease_cases_extracted_fts_config'(k PRIMARY KEY, v) WITHOUT ROWID; +CREATE TRIGGER disease_cases_extracted_fts_insert +AFTER INSERT ON disease_cases BEGIN + INSERT INTO disease_cases_extracted_fts( + rowid, job_name, job_description, main_reasoning, + other_harmful_factors, aggravating_factors + ) VALUES ( + new.id, new.job_name, new.job_description, new.main_reasoning, + new.other_harmful_factors, new.aggravating_factors + ); +END; +CREATE TRIGGER disease_cases_extracted_fts_delete +AFTER DELETE ON disease_cases BEGIN + INSERT INTO disease_cases_extracted_fts( + disease_cases_extracted_fts, + rowid, job_name, job_description, main_reasoning, + other_harmful_factors, aggravating_factors + ) VALUES ( + 'delete', + old.id, old.job_name, old.job_description, old.main_reasoning, + old.other_harmful_factors, old.aggravating_factors + ); +END; +CREATE TRIGGER disease_cases_extracted_fts_update +AFTER UPDATE ON disease_cases BEGIN + INSERT INTO disease_cases_extracted_fts( + disease_cases_extracted_fts, + rowid, job_name, job_description, main_reasoning, + other_harmful_factors, aggravating_factors + ) VALUES ( + 'delete', + old.id, old.job_name, old.job_description, old.main_reasoning, + old.other_harmful_factors, old.aggravating_factors + ); + INSERT INTO disease_cases_extracted_fts( + rowid, job_name, job_description, main_reasoning, + other_harmful_factors, aggravating_factors + ) VALUES ( + new.id, new.job_name, new.job_description, new.main_reasoning, + new.other_harmful_factors, new.aggravating_factors + ); +END; INSERT INTO "schema_migrations" (version) VALUES +('20260824000004'), +('20260824000003'), +('20260824000002'), +('20260824000001'), ('20260401000001'), ('20260327000009'), ('20260327000008'), @@ -128,3 +208,4 @@ INSERT INTO "schema_migrations" (version) VALUES ('20260327000001'), ('20260326000002'), ('20260326000001'); + diff --git a/docs/workercare-search.plan.md b/docs/workercare-search.plan.md index 821a77c..3497073 100644 --- a/docs/workercare-search.plan.md +++ b/docs/workercare-search.plan.md @@ -406,6 +406,17 @@ class CreateDiseaseCasesExtractedFts < ActiveRecord::Migration[8.1] ); SQL + # 구현 중 실제로 발생한 치명적 버그: disease_cases에 이미 61,815건이 존재하는 상태에서 위 + # CREATE VIRTUAL TABLE만 실행하면 shadow index가 비어있는 채로 남는다. 그 상태에서 트리거 + # 없이 바로 아래 update/delete 트리거를 만들고 임포트 rake task로 기존 레코드를 UPDATE하면, + # "색인된 적 없는 rowid"에 대해 delete pseudo-row를 시도하게 되어 FTS5 내부 색인이 깨진다 + # (SQLite3::CorruptException: database disk image is malformed — 전체 disease_cases 테이블 + # UPDATE가 즉시 이 예외로 실패했다). 트리거를 만들기 전에 반드시 먼저 rebuild로 현재 상태를 + # 색인해야 한다. + execute <<~SQL + INSERT INTO disease_cases_extracted_fts(disease_cases_extracted_fts) VALUES('rebuild'); + SQL + # INSERT/UPDATE/DELETE triggers (기존 패턴과 동일) execute <<~SQL CREATE TRIGGER disease_cases_extracted_fts_insert @@ -698,6 +709,8 @@ end | FTS5 가상 테이블 2개 동시 유지 | 마이그레이션 복잡도 | 기존/신규 트리거를 별도 마이그레이션 파일로 분리. `db:migrate` 순서 명확히 | | 기존 검색 경로 변경 | 외부 링크/북마크 깨짐 | README, MCP 문서, `workercare.plan.md` 경로 동시 업데이트. `/`는 그대로 유효하므로 영향 최소 | | `ksco_codes_json`이 비어있을 때 삭제 동기화를 건너뜀 (7.2절) | 추출 결과가 정말로 "매칭 없음"으로 바뀐 케이스에서 오래된 KSCO 매핑이 남아있을 수 있음 (false positive 가능성) | 의도된 안전한 기본값 — "비어있음"과 "필드 누락/파싱 실패"를 구분할 수 없는 한 삭제보다 보존을 택함. 추출기 계약이 "빈 값 = 매칭 없음 확정"을 보장하게 되면 이 가드를 제거하고 빈 값에서도 `destroy_all`을 실행하도록 전환 | +| 기존 데이터 위에 FTS5 external content 테이블을 새로 추가 | rebuild 없이 트리거만 만들면 기존 레코드 UPDATE 시 FTS5 색인이 깨짐 (`SQLite3::CorruptException`) — 실제 구현 중 발생·확인됨 | 5.4절 마이그레이션에서 `CREATE VIRTUAL TABLE` 직후, 트리거 생성 전에 `INSERT INTO disease_cases_extracted_fts(disease_cases_extracted_fts) VALUES('rebuild')`를 먼저 실행 | +| `SearchDiseaseCasesTool#build_statistics`가 `bm25` ORDER가 걸린 scope에 `.group(:result).count` 호출 | FTS 매칭 결과가 있을 때마다 `SQLite3::SQLException: unable to use function bm25 in the requested context`로 실패 (기존 MCP 테스트는 테스트 DB에 데이터가 없어 항상 fallback 경로만 타서 발견되지 않았던 기존 버그) — 실제 구현 중 발생·확인됨 | `scope.reorder(nil).group(:result).count`로 집계 전 order를 제거 | --- diff --git a/lib/tasks/import_extracted_disease_cases.rake b/lib/tasks/import_extracted_disease_cases.rake new file mode 100644 index 0000000..3f40d9e --- /dev/null +++ b/lib/tasks/import_extracted_disease_cases.rake @@ -0,0 +1,111 @@ +namespace :import do + desc "추출된 직업/근무조건/신체부위/사망/신청서 데이터 CSV 임포트\n" \ + " 사용법: rails 'import:extracted_disease_cases[wip/extract_disease_cases_details_cerebras-ksco.csv]'" + task :extracted_disease_cases, [:path] => :environment do |_, args| + require "csv" + require "json" + path = args[:path] or abort "CSV 경로를 지정하세요." + raise "파일 없음: #{path}" unless File.exist?(path) + + rows = CSV.read(path, headers: true, encoding: "UTF-8") + puts "추출 데이터: #{rows.size}건" + + imported = 0 + errors = 0 + + rows.each do |row| + case_no = row["case_no"].to_s.strip + record = DiseaseCase.find_by(case_no: case_no) + unless record + warn "판정서 없음: #{case_no}" + errors += 1 + next + end + + # 1. 기존 DiseaseCase 속성 업데이트 (숫자 필드는 반드시 to_i/to_d로 저장 — job_tenure_months/max_item_weight/ + # daily_total_weight는 wip/cerebras_prompts.rb 추출 스키마상 숫자이므로 문자열로 남기지 않는다) + record.update!( + job_name: row["직종명"].to_s.strip, + job_description: row["담당_업무"].to_s.strip, + employment_type: row["고용_형태"].to_s.strip, + work_type: row["근무_형태"].to_s.strip, + job_tenure_months: row["현_직종_총_종사기간"].to_s.strip.presence&.to_i, + weekly_work_hours: row["1주_평균_근무시간"].to_s.strip.presence&.to_d, + daily_work_hours: row["1일_평균_근무시간"].to_s.strip.presence&.to_d, + burden_body_part: row["부담_신체_부위"].to_s.strip, + bad_posture: row["주요_부적절한_자세"].to_s.strip, + heavy_lifting: row["중량물_취급_여부"].to_s.strip, + max_item_weight: row["취급_물품_최대_무게"].to_s.strip.presence&.to_d, + daily_total_weight: row["1일_취급_총_누적_중량"].to_s.strip.presence&.to_d, + other_harmful_factors: row["기타_유해요인_노출"].to_s.strip, + work_relevance_eval: row["업무관련성_평가"].to_s.strip, + aggravating_factors: row["업묵부담_가중요인_노출"].to_s.strip, + main_reasoning: row["판단_주요_근거"].to_s.strip, + death_status: row["사망_여부"].to_s.strip, + application_type: row["신청서_종류"].to_s.strip + ) + + # 1-1. 최종_산재_인정_여부와 기존 result 비교 — 덮어쓰지 않고 불일치만 로그 + # (import_disease_cases.rake의 전역 상수 RESULT_MAP을 그대로 재사용) + extracted_result_raw = row["최종_산재_인정_여부"].to_s.strip + extracted_result = RESULT_MAP[extracted_result_raw] + if extracted_result && record.result.present? && record.result != extracted_result + warn "result 불일치 (case_no=#{case_no}): 기존=#{record.result} 추출=#{extracted_result}" + end + + # 2. KSCO 매핑 동기화 (JSON 파싱) — 이번 CSV의 ksco_codes_json을 "전체 목표 집합"으로 취급한다. + # 이 데이터는 추출 프롬프트/모델이 개선될 때마다 재추출·재임포트되므로, upsert만 하면 + # 이전에 붙었지만 이번에는 빠진 코드가 그대로 남아 ksco_code 필터가 오래된(false positive) + # 매핑까지 매칭하게 된다. 그래서 파싱에 성공했을 때만 "현재 JSON에 없는 매핑은 제거"까지 함께 수행한다. + # current_codes는 "이번 JSON에 언급된 코드 전체"여야 한다 — KscoCode 테이블에 아직 없어서 + # skip한 코드도 포함해야, 참조 데이터 누락이 기존 매핑의 데이터 손실로 이어지지 않는다. + ksco_json = row["ksco_codes_json"].to_s.strip + if ksco_json.present? + parsed = + begin + JSON.parse(ksco_json) + rescue JSON::ParserError => e + warn "ksco_codes_json 파싱 실패, KSCO 매핑 동기화 건너뜀 (case_no=#{case_no}): #{e.message}" + nil + end + + if parsed + current_codes = [] + parsed.each do |item| + code = item["ksco_code"].to_s.strip + next if code.blank? + + current_codes << code + + ksco = KscoCode.find_by(code: code) + unless ksco + warn "KSCO 코드 없음: #{code} (case_no=#{case_no}) — 매핑은 유지, 참조 데이터 보완 후 재실행 필요" + next + end + + record.disease_case_ksco_codes.find_or_initialize_by(ksco_code_id: code).update!( + similarity: item["similarity"].to_f + ) + end + + # JSON 파싱에 성공했을 때만 "이번 JSON에 없는 매핑 제거"를 수행한다. + record.disease_case_ksco_codes.where.not(ksco_code_id: current_codes).destroy_all + end + end + # ksco_codes_json이 비어있거나 파싱 실패한 행은 삭제 동기화를 건너뛴다 — "비어있음"과 + # "필드 누락/파싱 실패"를 구분할 수 없으므로 삭제보다 보존을 택하는 안전한 기본값이다. + + imported += 1 + rescue => e + errors += 1 + warn "오류 (case_no=#{case_no}): #{e.message}" + end + + puts "추출 데이터 임포트 완료: #{imported}건 / 오류: #{errors}건" + + ActiveRecord::Base.connection.execute( + "INSERT INTO disease_cases_extracted_fts(disease_cases_extracted_fts) VALUES('rebuild')" + ) + puts "Extracted FTS rebuild 완료" + end +end diff --git a/lib/tasks/import_ksco_codes.rake b/lib/tasks/import_ksco_codes.rake new file mode 100644 index 0000000..34ec80d --- /dev/null +++ b/lib/tasks/import_ksco_codes.rake @@ -0,0 +1,23 @@ +namespace :import do + desc "KSCO(한국표준직업분류) 코드 CSV 임포트\n" \ + " 사용법: rails 'import:ksco_codes[wip/ksco-level-4-details.csv]'" + task :ksco_codes, [:path] => :environment do |_, args| + require "csv" + path = args[:path] or abort "CSV 경로를 지정하세요." + raise "파일 없음: #{path}" unless File.exist?(path) + + imported = 0 + CSV.foreach(path, headers: true, encoding: "UTF-8") do |row| + KscoCode.find_or_initialize_by(code: row["코드"].to_s.strip).update!( + name: row["세분류"].to_s.strip, + minor: row["소분류"].to_s.strip, + submajor: row["중분류"].to_s.strip, + major: row["대분류"].to_s.strip, + job_examples: row["직업예시"].to_s.strip, + exclusions: row["직업제외"].to_s.strip + ) + imported += 1 + end + puts "KSCO 임포트 완료: #{imported}건" + end +end diff --git a/test/controllers/disease_cases_controller_test.rb b/test/controllers/disease_cases_controller_test.rb new file mode 100644 index 0000000..7ebd05f --- /dev/null +++ b/test/controllers/disease_cases_controller_test.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "test_helper" + +class DiseaseCasesControllerTest < ActionDispatch::IntegrationTest + test "GET / renders the new main search screen" do + get root_path + assert_response :success + end + + test "GET / applies structured filters without a query" do + get root_path, params: { death_status: "Y" } + assert_response :success + end + + test "GET /search still renders the legacy advanced search screen" do + get search_path + assert_response :success + end + + test "GET /search with query still works (regression)" do + get search_path, params: { q: "손목" } + assert_response :success + end +end diff --git a/test/mcp/tools/search_disease_cases_tool_test.rb b/test/mcp/tools/search_disease_cases_tool_test.rb index 9e13431..273a4db 100644 --- a/test/mcp/tools/search_disease_cases_tool_test.rb +++ b/test/mcp/tools/search_disease_cases_tool_test.rb @@ -48,4 +48,38 @@ class SearchDiseaseCasesToolTest < ActiveSupport::TestCase assert json[:error].present? assert_match(/날짜 형식/, json[:error]) end + + test "job_name filters results and is reflected in match_reason" do + disease_case = DiseaseCase.create!(case_no: "TEST-MCP-JOB", job_name: "버스 운전원", + applicant_claim: "손목 통증 발생", year: 2024) + + result = SearchDiseaseCasesTool.call(q: "손목", job_name: "운전원") + json = result.structured_content + + assert_nil json[:error] + matched = json[:data][:cases].find { |c| c[:case_no] == disease_case.case_no } + assert matched, "job_name으로 필터링된 판정서가 결과에 포함되어야 한다" + assert_includes matched[:match_reason].map { |r| r[:reason] }, "동일 직종" + end + + test "death_status filters out non-matching cases" do + disease_case = DiseaseCase.create!(case_no: "TEST-MCP-DEATH", death_status: "Y", + applicant_claim: "손목 통증 발생", year: 2024) + + result = SearchDiseaseCasesTool.call(q: "손목", death_status: "N") + json = result.structured_content + + refute json[:data][:cases].map { |c| c[:case_no] }.include?(disease_case.case_no) + end + + test "ksco_code filters via disease_case_ksco_codes join" do + disease_case = DiseaseCase.create!(case_no: "TEST-MCP-KSCO", applicant_claim: "손목 통증 발생", year: 2024) + ksco = KscoCode.create!(code: "TEST-MCP-9999", name: "테스트 직업") + DiseaseCaseKscoCode.create!(disease_case: disease_case, ksco_code: ksco, similarity: 1.0) + + result = SearchDiseaseCasesTool.call(q: "손목", ksco_code: [ ksco.code ]) + json = result.structured_content + + assert_includes json[:data][:cases].map { |c| c[:case_no] }, disease_case.case_no + end end diff --git a/test/models/disease_cases/main_searchable_test.rb b/test/models/disease_cases/main_searchable_test.rb new file mode 100644 index 0000000..687d22d --- /dev/null +++ b/test/models/disease_cases/main_searchable_test.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "test_helper" + +class DiseaseCases::MainSearchableTest < ActiveSupport::TestCase + setup do + @neck = DiseaseCase.create!(case_no: "TEST-MAIN-NECK", job_name: "버스 운전원", + job_description: "시내버스 운행", burden_body_part: "목", death_status: "N", + application_type: "요양급여", year: 2024) + @wrist = DiseaseCase.create!(case_no: "TEST-MAIN-WRIST", job_name: "조립공", + job_description: "부품 조립", burden_body_part: "손목", death_status: "N", + application_type: "요양급여", year: 2023) + @death = DiseaseCase.create!(case_no: "TEST-MAIN-DEATH", job_name: "택배기사", + job_description: "배송 업무", burden_body_part: "허리|목", death_status: "Y", + application_type: "유족급여", year: 2022) + + @ksco = KscoCode.create!(code: "TEST-8722", name: "버스 운전원") + DiseaseCaseKscoCode.create!(disease_case: @neck, ksco_code: @ksco, similarity: 0.9) + end + + test "burden_body_part filter matches token exactly, not as a substring" do + scope, = DiseaseCase.main_search(burden_body_part: [ "목" ]) + results = scope.where(case_no: [ @neck.case_no, @wrist.case_no, @death.case_no ]) + + assert_includes results, @neck + assert_includes results, @death + refute_includes results, @wrist + end + + test "job_name filter does partial match" do + scope, = DiseaseCase.main_search(job_name: "운전") + assert_includes scope, @neck + refute_includes scope, @wrist + end + + test "death_status filter" do + scope, = DiseaseCase.main_search(death_status: "Y") + assert_includes scope, @death + refute_includes scope, @neck + end + + test "ksco_code filter joins through disease_case_ksco_codes" do + scope, = DiseaseCase.main_search(ksco_code: [ @ksco.code ]) + assert_includes scope, @neck + refute_includes scope, @wrist + end + + test "structured filters without a query do not force bm25 ordering" do + scope, fallback = DiseaseCase.main_search(death_status: "N") + assert_not fallback + assert_nothing_raised { scope.to_a } + end + + test "full text search over job_description finds matches via disease_cases_extracted_fts" do + scope, fallback = DiseaseCase.main_search(q: "배송") + assert_includes scope, @death + assert_not fallback + end +end From 4912d7c8bc6541cf5948092a7e6c60a6504a93a4 Mon Sep 17 00:00:00 2001 From: wagurano Date: Wed, 2 Sep 2026 22:15:26 +0900 Subject: [PATCH 03/24] =?UTF-8?q?=EB=A3=A8=EB=B9=84=20=EB=B2=84=EC=A0=84?= =?UTF-8?q?=20=EA=B4=80=EB=A6=AC=EB=A5=BC=20asdf=20.tool-versions=EB=A1=9C?= =?UTF-8?q?=20=EC=98=AE=EA=B9=81=EB=8B=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .ruby-version 대신 여러 도구 버전을 한 곳에서 관리하는 .tool-versions를 사용합니다. --- .ruby-version | 1 - .tool-versions | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 .ruby-version create mode 100644 .tool-versions diff --git a/.ruby-version b/.ruby-version deleted file mode 100644 index 1cf76f5..0000000 --- a/.ruby-version +++ /dev/null @@ -1 +0,0 @@ -ruby-4.0.6 diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..05913ea --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +ruby 4.0.6 From ae0ea9497ce439f2628a097dd65765af328cc994 Mon Sep 17 00:00:00 2001 From: wagurano Date: Wed, 2 Sep 2026 22:15:32 +0900 Subject: [PATCH 04/24] =?UTF-8?q?=EC=A7=81=EC=97=85=C2=B7=EC=8B=A0?= =?UTF-8?q?=EC=B2=B4=EB=B6=80=EC=9C=84=20=EA=B2=80=EC=83=89=EC=9A=A9=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=A1=9C=EB=93=9C=20=EB=B0=A9?= =?UTF-8?q?=EB=B2=95=EC=9D=84=20README=EC=97=90=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=A9=EB=8B=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 메인 화면(/)의 직업·근무조건·신체부위·사망여부·신청서유형 필터와 KSCO 직업분류 연동을 위해 필요한 rake task 실행 순서를 안내합니다. --- README.md | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b31b43c..de3c769 100644 --- a/README.md +++ b/README.md @@ -50,12 +50,25 @@ FTS5 인덱스를 수동으로 재구성하려면: rails fts:rebuild ``` +### 직업·신체부위 기반 검색용 데이터 로드 + +메인 화면(`/`)의 직업·근무조건·신체부위·사망여부·신청서유형 필터와 KSCO 직업분류 연동을 위해, 위 임포트 이후 아래 두 rake task를 **순서대로** 실행합니다 (마이그레이션만으로는 데이터가 채워지지 않습니다). + +```bash +rails "import:ksco_codes[wip/ksco-level-4-details.csv]" +rails "import:extracted_disease_cases[wip/extract_disease_cases_details_cerebras-ksco.csv]" +``` + +- `import:ksco_codes`: 한국표준직업분류(KSCO) 4단계 코드 체계를 `KscoCode`로 임포트합니다. +- `import:extracted_disease_cases`: LLM으로 추출한 직업·근무조건·신체부위·사망여부·신청서유형과 KSCO 매핑(`DiseaseCaseKscoCode`, 유사도 포함)을 기존 `DiseaseCase`에 채우고, `disease_cases_extracted_fts` 인덱스를 rebuild합니다. +- 자세한 설계와 데이터 모델은 [docs/workercare-search.plan.md](docs/workercare-search.plan.md)를 참고합니다. + ## 주요 화면 | 경로 | 설명 | |------|------| -| `GET /` | 메인 검색 (전문 검색) | -| `GET /search` | 상세 검색 (전문 검색 + 필터) | +| `GET /` | 메인 검색 (직업·근무조건·신체부위·사망여부·신청서유형 필터 + 전문 검색) | +| `GET /search` | 상세 검색 (심의결과·질병분류·신체부위·판정일 필터 + 전문 검색) | | `GET /disease_cases/:id` | 판정서 상세 | ## MCP 서버 @@ -71,7 +84,7 @@ https://..../mcp | Tool | 설명 | |------|------| -| `search_disease_cases` | 판정서 전문 검색 + 통계 집계 | +| `search_disease_cases` | 판정서 전문 검색 + 직업/사망여부/KSCO 코드 구조화 필터 + 통계 집계 | | `compare_approval_factors` | 인정/불인정 사례 패턴 비교 | | `suggest_evidence` | 필요 증거 자료 제안 (룰 기반) | | `get_procedure_guide` | 산재 신청 절차 안내 | @@ -119,6 +132,7 @@ Access Token(비밀번호)이 비어 있으면 스크립트를 멈춥니다. - [docs/workercare.plan.md](docs/workercare.plan.md) — 검색 서비스, FTS5, enum, 데이터 설계 - [docs/workercare-mcp.plan.md](docs/workercare-mcp.plan.md) — MCP 컴포넌트 설계, Tool/Prompt 명세 +- [docs/workercare-search.plan.md](docs/workercare-search.plan.md) — 직업·신체부위·사망여부 기반 메인 검색 화면, KSCO 직업분류 연동 설계 ## 라이선스 From 7d18c97aecb3e048517cbc299ffedf50ad2ff27a Mon Sep 17 00:00:00 2001 From: wagurano Date: Wed, 2 Sep 2026 22:15:39 +0900 Subject: [PATCH 05/24] =?UTF-8?q?=EB=A1=9C=EC=BB=AC=20dev=EC=9A=A9=20overm?= =?UTF-8?q?ind=EB=A5=BC=20gem=EC=9C=BC=EB=A1=9C=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=A9=EB=8B=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bin/dev(Procfile.dev 실행)가 Homebrew의 overmind와 낡은 asdf 셰임(다른 프로젝트의 overmind gem용, ruby 3.4.x)이 충돌해 "No version is set for command overmind"로 실패했습니다. mix4처럼 overmind를 프로젝트 gem으로 관리하면 bundle exec가 asdf 셰임보다 먼저 이 ruby 버전의 gem bin을 찾아 충돌 없이 해결됩니다. --- Gemfile | 3 +++ Gemfile.lock | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/Gemfile b/Gemfile index 7b14186..efb697e 100644 --- a/Gemfile +++ b/Gemfile @@ -62,6 +62,9 @@ group :development do # Use console on exceptions pages [ht # tps://github.com/rails/web-console] gem "web-console" + + # Process manager for bin/dev (Procfile.dev) [https://github.com/DarthSim/overmind] + gem "overmind" end gem "actionmcp", "~> 0.107.1" diff --git a/Gemfile.lock b/Gemfile.lock index 47badec..f3f9443 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -266,6 +266,10 @@ GEM racc (~> 1.4) openssl (4.0.1) ostruct (0.6.3) + overmind (2.5.1) + overmind (2.5.1-aarch64-linux) + overmind (2.5.1-arm-linux) + overmind (2.5.1-arm64-darwin) pagy (43.5.0) json uri @@ -485,6 +489,7 @@ DEPENDENCIES importmap-rails jbuilder kamal + overmind pagy (~> 43.5) propshaft puma (>= 5.0) @@ -593,6 +598,10 @@ CHECKSUMS nokogiri (1.19.4-x86_64-linux-musl) sha256=17dfb7c1fa194ae02fbf7c51a7afc8d278045ab3fdacfd86f91d02d7b274470b openssl (4.0.1) sha256=e27974136b7b02894a1bce46c5397ee889afafe704a839446b54dc81cb9c5f7d ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 + overmind (2.5.1) sha256=df4676e787845173c3929029d2b2952055ffaf02fde0106054f98f4c2611ec9a + overmind (2.5.1-aarch64-linux) sha256=13a979e61278fb5a214600e844268460a86787a8f1a8ed962dcf6aa018f3f50e + overmind (2.5.1-arm-linux) sha256=5e12b5fb973dce118769648cf6549a48a0d424f947b40fbb30012f53308d23e1 + overmind (2.5.1-arm64-darwin) sha256=8b1c9ea304ef524eaf335b4524d5dbd58e02ed9492397243f61cc49a8bd4eca7 pagy (43.5.0) sha256=58885d5f659e8db5b92cf35eeba674113e4e7bda12649b603c2d6908402570a4 parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130 parser (3.3.10.2) sha256=6f60c84aa4bdcedb6d1a2434b738fe8a8136807b6adc8f7f53b97da9bc4e9357 From a37e275b76ee49e7aba2622a4bd863d194379e8b Mon Sep 17 00:00:00 2001 From: wagurano Date: Wed, 2 Sep 2026 22:15:50 +0900 Subject: [PATCH 06/24] =?UTF-8?q?=EB=B6=80=EB=8B=B4=20=EC=8B=A0=EC=B2=B4?= =?UTF-8?q?=20=EB=B6=80=EC=9C=84=20=EC=B2=B4=ED=81=AC=EB=B0=95=EC=8A=A4?= =?UTF-8?q?=EB=A5=BC=20=EC=83=81=EC=9C=84=2012=EA=B0=9C=EB=A1=9C=20?= =?UTF-8?q?=EC=A4=84=EC=9D=B4=EA=B3=A0=20=EB=82=98=EB=A8=B8=EC=A7=80?= =?UTF-8?q?=EB=8A=94=20=EC=9E=90=EB=8F=99=EC=99=84=EC=84=B1=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=B0=BE=EC=8A=B5=EB=8B=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit burden_body_part는 LLM이 추출한 파이프 구분 자유 텍스트라 실 데이터 기준 distinct 토큰이 1,000개를 넘어 체크박스가 감당할 수 없이 많았습니다. 빈도 상위 12개만 체크박스로 노출하고, 나머지는 네이티브 자동완성 텍스트 입력으로 찾도록 바꿉니다. 자유 텍스트 필터도 기존 체크박스와 동일한 파이프 경계 인식 LIKE 매칭을 재사용해 "목" 검색이 "손목"까지 잘못 걸리지 않습니다. --- app/controllers/disease_cases_controller.rb | 26 ++++++++++++++----- .../concerns/disease_cases/main_searchable.rb | 3 ++- app/views/disease_cases/main.html.erb | 10 +++++++ config/locales/en.yml | 2 ++ config/locales/ko.yml | 2 ++ .../disease_cases_controller_test.rb | 9 +++++++ .../disease_cases/main_searchable_test.rb | 18 +++++++++++++ 7 files changed, 63 insertions(+), 7 deletions(-) diff --git a/app/controllers/disease_cases_controller.rb b/app/controllers/disease_cases_controller.rb index 40b9864..d6b741a 100644 --- a/app/controllers/disease_cases_controller.rb +++ b/app/controllers/disease_cases_controller.rb @@ -1,5 +1,6 @@ class DiseaseCasesController < ApplicationController MAX_SEARCH_RESULTS = 500 + BURDEN_BODY_PART_CHECKBOX_LIMIT = 12 # 기존 /search (이전에는 root) def index @@ -10,6 +11,7 @@ def index def main perform_search(legacy: false) @burden_body_part_options = burden_body_part_options + @burden_body_part_datalist_options = burden_body_part_datalist_options @application_type_options = application_type_options end @@ -28,13 +30,24 @@ def perform_search(legacy:) log_search_event end - # burden_body_part는 파이프(|) 구분 다중값 텍스트이므로, 체크박스 후보 목록은 저장된 값을 - # split해서 만든다 (사전 정의된 enum이 아님 — 3.2절 참고). + # burden_body_part는 파이프(|) 구분 다중값 텍스트이며 실 데이터 기준 distinct 토큰이 + # 1,000개를 넘는 자유 텍스트라(사전 정의된 enum이 아님 — 3.2절 참고), 체크박스로는 + # 자주 쓰는 상위 N개만 노출하고 나머지는 자동완성 텍스트 입력으로 찾는다. + def burden_body_part_token_counts + counts = Hash.new(0) + DiseaseCase.where.not(burden_body_part: [ nil, "" ]).pluck(:burden_body_part).each do |raw| + raw.split("|").each { |token| counts[token.strip] += 1 unless token.strip.blank? } + end + counts + end + def burden_body_part_options - DiseaseCase.where.not(burden_body_part: [ nil, "" ]) - .distinct.pluck(:burden_body_part) - .flat_map { |v| v.split("|") } - .map(&:strip).reject(&:blank?).uniq.sort + burden_body_part_token_counts.sort_by { |_, count| -count } + .first(BURDEN_BODY_PART_CHECKBOX_LIMIT).map(&:first).sort + end + + def burden_body_part_datalist_options + burden_body_part_token_counts.keys.sort end def application_type_options @@ -64,6 +77,7 @@ def main_search_params :q, :job_name, :job_description, :death_status, :application_type, :employment_type, :work_type, :work_relevance_eval, :sort, :commit, :search, + :burden_body_part_text, burden_body_part: [], ksco_code: [] ) end diff --git a/app/models/concerns/disease_cases/main_searchable.rb b/app/models/concerns/disease_cases/main_searchable.rb index 7e4a6a6..8ed3385 100644 --- a/app/models/concerns/disease_cases/main_searchable.rb +++ b/app/models/concerns/disease_cases/main_searchable.rb @@ -56,8 +56,9 @@ def apply_main_filters(scope, params) # 부담_신체_부위는 파이프(|)로 구분된 다중값 텍스트다(예: "목|상체|하체"). # exact match나 단순 LIKE '%값%'은 "목"이 "손목"/"발목"/"뒷목"까지 잘못 매칭하므로, # 파이프 경계를 인식하는 매칭(정확히 일치/맨앞/맨뒤/중간)으로 처리한다. - if params[:burden_body_part].present? + if params[:burden_body_part].present? || params[:burden_body_part_text].present? values = Array(params[:burden_body_part]).reject(&:blank?) + values << params[:burden_body_part_text].to_s.strip if params[:burden_body_part_text].present? if values.any? conditions = values.map { "(burden_body_part = ? OR burden_body_part LIKE ? OR burden_body_part LIKE ? OR burden_body_part LIKE ?)" diff --git a/app/views/disease_cases/main.html.erb b/app/views/disease_cases/main.html.erb index 86dcac8..e051a03 100644 --- a/app/views/disease_cases/main.html.erb +++ b/app/views/disease_cases/main.html.erb @@ -36,6 +36,16 @@ <% end %>
+
+ <%= form.label :burden_body_part_text, t("search.burden_body_part_text_label") %> + <%= form.text_field :burden_body_part_text, value: params[:burden_body_part_text], + list: "burden_body_part_datalist", placeholder: t("search.burden_body_part_text_placeholder") %> + + <% @burden_body_part_datalist_options.each do |value| %> + +
diff --git a/config/locales/en.yml b/config/locales/en.yml index 6a1b4bd..013f0dc 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -28,5 +28,7 @@ en: title: Occupational Disease Search main_title: Find Cases by Job & Body Part main_placeholder: Search by job, duties, reasoning... + burden_body_part_text_label: Search directly + burden_body_part_text_placeholder: "e.g. shoulder joint, lumbar spine" death_only: Fatal cases only go_to_advanced_search: Advanced Search (legacy) diff --git a/config/locales/ko.yml b/config/locales/ko.yml index 85ab74e..667dfe6 100644 --- a/config/locales/ko.yml +++ b/config/locales/ko.yml @@ -28,5 +28,7 @@ ko: title: 업무상질병 판정서 검색 main_title: 직업·신체 부위로 판정서 찾기 main_placeholder: 직업, 업무, 판단 근거로 검색... + burden_body_part_text_label: 직접 검색 + burden_body_part_text_placeholder: "예: 견관절, 요추" death_only: 사망 사례만 보기 go_to_advanced_search: 상세 검색 (기존 화면) diff --git a/test/controllers/disease_cases_controller_test.rb b/test/controllers/disease_cases_controller_test.rb index 7ebd05f..16fa9b9 100644 --- a/test/controllers/disease_cases_controller_test.rb +++ b/test/controllers/disease_cases_controller_test.rb @@ -13,6 +13,15 @@ class DiseaseCasesControllerTest < ActionDispatch::IntegrationTest assert_response :success end + test "GET / caps burden_body_part checkboxes and exposes the rest via a datalist" do + 15.times { |i| DiseaseCase.create!(case_no: "TEST-BBP-#{i}", burden_body_part: "부위#{i}", year: 2024) } + + get root_path + assert_response :success + assert_select "input[name='burden_body_part[]']", count: DiseaseCasesController::BURDEN_BODY_PART_CHECKBOX_LIMIT + assert_select "datalist#burden_body_part_datalist option", count: 15 + end + test "GET /search still renders the legacy advanced search screen" do get search_path assert_response :success diff --git a/test/models/disease_cases/main_searchable_test.rb b/test/models/disease_cases/main_searchable_test.rb index 687d22d..c1b7fc1 100644 --- a/test/models/disease_cases/main_searchable_test.rb +++ b/test/models/disease_cases/main_searchable_test.rb @@ -27,6 +27,24 @@ class DiseaseCases::MainSearchableTest < ActiveSupport::TestCase refute_includes results, @wrist end + test "burden_body_part_text filter matches token exactly, not as a substring" do + scope, = DiseaseCase.main_search(burden_body_part_text: "목") + results = scope.where(case_no: [ @neck.case_no, @wrist.case_no, @death.case_no ]) + + assert_includes results, @neck + assert_includes results, @death + refute_includes results, @wrist + end + + test "burden_body_part and burden_body_part_text combine as OR" do + scope, = DiseaseCase.main_search(burden_body_part: [ "손목" ], burden_body_part_text: "허리") + results = scope.where(case_no: [ @neck.case_no, @wrist.case_no, @death.case_no ]) + + assert_includes results, @wrist + assert_includes results, @death + refute_includes results, @neck + end + test "job_name filter does partial match" do scope, = DiseaseCase.main_search(job_name: "운전") assert_includes scope, @neck From 791359ea740570e919405e4cc8dd9f13a6900340 Mon Sep 17 00:00:00 2001 From: wagurano Date: Wed, 2 Sep 2026 22:53:18 +0900 Subject: [PATCH 07/24] =?UTF-8?q?=EB=AA=A9=EB=A1=9D=EC=9D=98=20=EC=82=AC?= =?UTF-8?q?=EB=A7=9D=20=EB=B0=B0=EC=A7=80=EB=A5=BC=20"=EC=82=AC=EB=A7=9D"?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=A4=84=EC=9D=B4=EA=B3=A0=20=EC=9D=8C?= =?UTF-8?q?=EC=98=81=20=ED=91=9C=EC=8B=9C=EB=A5=BC=20=EC=97=86=EC=95=B1?= =?UTF-8?q?=EB=8B=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 목록 각 행의 사망 배지가 체크박스 라벨("사망 사례만 보기")을 그대로 재사용해 행마다 반복 노출되던 문제를 고쳤습니다. 배지 전용 로케일 키(death_badge)를 따로 두고, 의 기본 음영 스타일 대신 을 씁니다. --- app/views/disease_cases/main/_disease_case.html.erb | 2 +- config/locales/en.yml | 1 + config/locales/ko.yml | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/views/disease_cases/main/_disease_case.html.erb b/app/views/disease_cases/main/_disease_case.html.erb index 8aac240..bee452f 100644 --- a/app/views/disease_cases/main/_disease_case.html.erb +++ b/app/views/disease_cases/main/_disease_case.html.erb @@ -4,7 +4,7 @@ <%= disease_case.application_type.presence || "-" %> <% if disease_case.death_status == "Y" %> - <%= t("search.death_only") %> + <%= t("search.death_badge") %> <% end %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 013f0dc..14c1002 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -31,4 +31,5 @@ en: burden_body_part_text_label: Search directly burden_body_part_text_placeholder: "e.g. shoulder joint, lumbar spine" death_only: Fatal cases only + death_badge: Fatal go_to_advanced_search: Advanced Search (legacy) diff --git a/config/locales/ko.yml b/config/locales/ko.yml index 667dfe6..49b2b3e 100644 --- a/config/locales/ko.yml +++ b/config/locales/ko.yml @@ -31,4 +31,5 @@ ko: burden_body_part_text_label: 직접 검색 burden_body_part_text_placeholder: "예: 견관절, 요추" death_only: 사망 사례만 보기 + death_badge: 사망 go_to_advanced_search: 상세 검색 (기존 화면) From 8618e791c61a0873fe3ca231cac39f25effb8fd8 Mon Sep 17 00:00:00 2001 From: wagurano Date: Wed, 2 Sep 2026 22:53:38 +0900 Subject: [PATCH 08/24] =?UTF-8?q?=EA=B2=80=EC=83=89=20=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=EC=97=90=EC=84=9C=20KSCO=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=EC=9D=84=20=EB=BA=8D=EB=8B=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KSCO 코드 태그가 목록 한 줄을 과도하게 길게 만들어 가독성을 해쳐서 뺍니다. 더 이상 목록에서 ksco_codes를 렌더링하지 않으므로 N+1 방지용 eager load (includes(:ksco_codes))도 함께 제거합니다. 판정서 상세 화면에는 영향 없습니다. --- app/controllers/disease_cases_controller.rb | 1 - app/views/disease_cases/_main_case_list.html.erb | 1 - app/views/disease_cases/main/_disease_case.html.erb | 5 ----- 3 files changed, 7 deletions(-) diff --git a/app/controllers/disease_cases_controller.rb b/app/controllers/disease_cases_controller.rb index d6b741a..2cd0583 100644 --- a/app/controllers/disease_cases_controller.rb +++ b/app/controllers/disease_cases_controller.rb @@ -24,7 +24,6 @@ def show def perform_search(legacy:) @scope, @fallback = legacy ? DiseaseCase.search(search_params) : DiseaseCase.main_search(main_search_params) @pagy, @cases = paginate(@scope) - @cases = @cases.includes(:ksco_codes) unless legacy @metadata = build_metadata log_search_event diff --git a/app/views/disease_cases/_main_case_list.html.erb b/app/views/disease_cases/_main_case_list.html.erb index 3b8c6f5..c4b4748 100644 --- a/app/views/disease_cases/_main_case_list.html.erb +++ b/app/views/disease_cases/_main_case_list.html.erb @@ -8,7 +8,6 @@ <%= DiseaseCase.human_attribute_name(:disease_name) %> <%= DiseaseCase.human_attribute_name(:result) %> <%= DiseaseCase.human_attribute_name(:year) %> - KSCO diff --git a/app/views/disease_cases/main/_disease_case.html.erb b/app/views/disease_cases/main/_disease_case.html.erb index bee452f..8aaaa2e 100644 --- a/app/views/disease_cases/main/_disease_case.html.erb +++ b/app/views/disease_cases/main/_disease_case.html.erb @@ -12,9 +12,4 @@ <%= disease_case.human_attribute_enum(:result) %> <%= disease_case.year %>년 - - <% disease_case.ksco_codes.each do |ksco| %> - <%= ksco.name %> - <% end %> - From 94b142c89d23dc0095a187ca313e36e94c1906e0 Mon Sep 17 00:00:00 2001 From: wagurano Date: Wed, 2 Sep 2026 23:15:50 +0900 Subject: [PATCH 09/24] =?UTF-8?q?KSCO=20=EB=A7=A4=ED=95=91=EC=9D=B4=20?= =?UTF-8?q?=EC=9E=88=EB=8A=94=20=ED=8C=90=EC=A0=95=EC=84=9C=EB=A5=BC=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=ED=95=98=EB=A9=B4=20FK=20=EC=98=A4=EB=A5=98?= =?UTF-8?q?=EA=B0=80=20=EB=82=98=EB=8D=98=20=EB=B2=84=EA=B7=B8=EB=A5=BC=20?= =?UTF-8?q?=EA=B3=A0=EC=B9=A9=EB=8B=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit disease_case_ksco_codes에 FK가 걸려있는데(20260824000002 마이그레이션) DiseaseCase#has_many :disease_case_ksco_codes에 dependent 옵션이 없어서, KSCO 매핑이 붙은 판정서를 destroy하면 ActiveRecord::InvalidForeignKey가 났습니다. dependent: :delete_all로 매핑을 함께 정리합니다. --- app/models/disease_case.rb | 2 +- test/models/disease_case_test.rb | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 test/models/disease_case_test.rb diff --git a/app/models/disease_case.rb b/app/models/disease_case.rb index d5f89cc..f25615d 100644 --- a/app/models/disease_case.rb +++ b/app/models/disease_case.rb @@ -3,7 +3,7 @@ class DiseaseCase < ApplicationRecord include DiseaseCases::Searchable include DiseaseCases::MainSearchable - has_many :disease_case_ksco_codes + has_many :disease_case_ksco_codes, dependent: :delete_all has_many :ksco_codes, through: :disease_case_ksco_codes enum :result, { diff --git a/test/models/disease_case_test.rb b/test/models/disease_case_test.rb new file mode 100644 index 0000000..34757de --- /dev/null +++ b/test/models/disease_case_test.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "test_helper" + +class DiseaseCaseTest < ActiveSupport::TestCase + test "destroying a case with mapped KSCO codes does not raise a foreign key error" do + disease_case = DiseaseCase.create!(case_no: "TEST-DESTROY-KSCO", year: 2024) + ksco_code = KscoCode.create!(code: "TEST-DESTROY-9999", name: "테스트 직업") + DiseaseCaseKscoCode.create!(disease_case: disease_case, ksco_code: ksco_code, similarity: 0.5) + + assert_nothing_raised { disease_case.destroy! } + assert_empty DiseaseCaseKscoCode.where(disease_case_id: disease_case.id) + end +end From fd268f6a6b9e3de62b220f7a3ed1bb8ec18e505d Mon Sep 17 00:00:00 2001 From: wagurano Date: Wed, 2 Sep 2026 23:16:06 +0900 Subject: [PATCH 10/24] =?UTF-8?q?GET=20/=20=EC=97=90=EC=84=9C=20burden=5Fb?= =?UTF-8?q?ody=5Fpart=20=ED=86=A0=ED=81=B0=20=EC=A7=91=EA=B3=84=20?= =?UTF-8?q?=ED=92=80=EC=8A=A4=EC=BA=94=EC=9D=84=20=ED=95=9C=20=EB=B2=88?= =?UTF-8?q?=EB=A7=8C=20=EB=8F=8C=EB=A6=BD=EB=8B=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit burden_body_part_options와 burden_body_part_datalist_options가 각각 burden_body_part_token_counts를 따로 호출해, 메인 화면 접속마다 같은 집계(비어있지 않은 burden_body_part 전체 pluck + 파이프 split)를 두 번 수행했습니다. main 액션에서 한 번만 계산해 두 메서드에 넘겨 재사용합니다. --- app/controllers/disease_cases_controller.rb | 13 +++++++------ .../disease_cases_controller_test.rb | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/app/controllers/disease_cases_controller.rb b/app/controllers/disease_cases_controller.rb index 2cd0583..77e62cc 100644 --- a/app/controllers/disease_cases_controller.rb +++ b/app/controllers/disease_cases_controller.rb @@ -10,8 +10,9 @@ def index # 신규 / (메인 화면) def main perform_search(legacy: false) - @burden_body_part_options = burden_body_part_options - @burden_body_part_datalist_options = burden_body_part_datalist_options + counts = burden_body_part_token_counts + @burden_body_part_options = burden_body_part_options(counts) + @burden_body_part_datalist_options = burden_body_part_datalist_options(counts) @application_type_options = application_type_options end @@ -40,13 +41,13 @@ def burden_body_part_token_counts counts end - def burden_body_part_options - burden_body_part_token_counts.sort_by { |_, count| -count } + def burden_body_part_options(counts) + counts.sort_by { |_, count| -count } .first(BURDEN_BODY_PART_CHECKBOX_LIMIT).map(&:first).sort end - def burden_body_part_datalist_options - burden_body_part_token_counts.keys.sort + def burden_body_part_datalist_options(counts) + counts.keys.sort end def application_type_options diff --git a/test/controllers/disease_cases_controller_test.rb b/test/controllers/disease_cases_controller_test.rb index 16fa9b9..4300f72 100644 --- a/test/controllers/disease_cases_controller_test.rb +++ b/test/controllers/disease_cases_controller_test.rb @@ -22,6 +22,24 @@ class DiseaseCasesControllerTest < ActionDispatch::IntegrationTest assert_select "datalist#burden_body_part_datalist option", count: 15 end + test "GET / computes burden_body_part token counts only once" do + 3.times { |i| DiseaseCase.create!(case_no: "TEST-BBP-QUERY-#{i}", burden_body_part: "부위#{i}", year: 2024) } + + pluck_count = 0 + subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload| + pluck_count += 1 if payload[:name] == "DiseaseCase Pluck" && payload[:sql].include?("burden_body_part") + end + + begin + get root_path + ensure + ActiveSupport::Notifications.unsubscribe(subscriber) + end + + assert_response :success + assert_equal 1, pluck_count + end + test "GET /search still renders the legacy advanced search screen" do get search_path assert_response :success From 6a74c772038c8901040b1f7839d526eca728367a Mon Sep 17 00:00:00 2001 From: wagurano Date: Wed, 2 Sep 2026 23:23:46 +0900 Subject: [PATCH 11/24] =?UTF-8?q?=EC=83=81=EC=84=B8=20=EA=B2=80=EC=83=89(/?= =?UTF-8?q?search)=EC=97=90=20=EB=A9=94=EC=9D=B8=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=EC=9D=98=20=EC=A7=81=EC=97=85=C2=B7=EB=B6=80=EB=8B=B4=20?= =?UTF-8?q?=EC=8B=A0=EC=B2=B4=20=EB=B6=80=EC=9C=84=C2=B7=EC=82=AC=EB=A7=9D?= =?UTF-8?q?=20=EC=97=AC=EB=B6=80=C2=B7=EC=8B=A0=EC=B2=AD=EC=84=9C=20?= =?UTF-8?q?=EC=9C=A0=ED=98=95=20=ED=95=84=ED=84=B0=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=A9=EB=8B=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DiseaseCases::MainSearchable.apply_main_filters가 MCP search_disease_cases_tool처럼 legacy DiseaseCase.search 결과에 재사용하도록 이미 public으로 열려있어(주석 참고), 같은 패턴으로 index 액션에도 적용합니다. /search에서 기존 심의결과·질병분류· 신체부위·판정일 필터에 더해 직업(직종명/담당업무), 부담 신체 부위(체크박스+자동완성), 사망 여부, 신청서 유형까지 조합해 검색할 수 있습니다. /(메인 화면)의 동작은 그대로입니다. --- app/controllers/disease_cases_controller.rb | 23 ++++++--- app/views/disease_cases/index.html.erb | 47 +++++++++++++++++++ .../disease_cases_controller_test.rb | 19 ++++++++ 3 files changed, 83 insertions(+), 6 deletions(-) diff --git a/app/controllers/disease_cases_controller.rb b/app/controllers/disease_cases_controller.rb index 77e62cc..ac231fb 100644 --- a/app/controllers/disease_cases_controller.rb +++ b/app/controllers/disease_cases_controller.rb @@ -2,18 +2,17 @@ class DiseaseCasesController < ApplicationController MAX_SEARCH_RESULTS = 500 BURDEN_BODY_PART_CHECKBOX_LIMIT = 12 - # 기존 /search (이전에는 root) + # 기존 /search (이전에는 root). 메인 화면(직업·부담 신체 부위·사망 여부·신청서 유형) 필터도 + # 함께 쓸 수 있도록 apply_main_filters를 추가로 적용한다. def index perform_search(legacy: true) + set_main_filter_options end # 신규 / (메인 화면) def main perform_search(legacy: false) - counts = burden_body_part_token_counts - @burden_body_part_options = burden_body_part_options(counts) - @burden_body_part_datalist_options = burden_body_part_datalist_options(counts) - @application_type_options = application_type_options + set_main_filter_options end def show @@ -23,13 +22,25 @@ def show private def perform_search(legacy:) - @scope, @fallback = legacy ? DiseaseCase.search(search_params) : DiseaseCase.main_search(main_search_params) + if legacy + @scope, @fallback = DiseaseCase.search(search_params) + @scope = DiseaseCase.apply_main_filters(@scope, main_search_params) + else + @scope, @fallback = DiseaseCase.main_search(main_search_params) + end @pagy, @cases = paginate(@scope) @metadata = build_metadata log_search_event end + def set_main_filter_options + counts = burden_body_part_token_counts + @burden_body_part_options = burden_body_part_options(counts) + @burden_body_part_datalist_options = burden_body_part_datalist_options(counts) + @application_type_options = application_type_options + end + # burden_body_part는 파이프(|) 구분 다중값 텍스트이며 실 데이터 기준 distinct 토큰이 # 1,000개를 넘는 자유 텍스트라(사전 정의된 enum이 아님 — 3.2절 참고), 체크박스로는 # 자주 쓰는 상위 N개만 노출하고 나머지는 자동완성 텍스트 입력으로 찾는다. diff --git a/app/views/disease_cases/index.html.erb b/app/views/disease_cases/index.html.erb index e3901de..5698f53 100644 --- a/app/views/disease_cases/index.html.erb +++ b/app/views/disease_cases/index.html.erb @@ -10,6 +10,16 @@

<%= t("search.loading") %>

+
+
+ <%= form.label :job_name, DiseaseCase.human_attribute_name(:job_name) %> + <%= form.text_field :job_name, value: params[:job_name] %> +
+
+ <%= form.label :job_description, DiseaseCase.human_attribute_name(:job_description) %> + <%= form.text_field :job_description, value: params[:job_description] %> +
+
<%= t("search.search_in_columns") %> @@ -65,6 +75,31 @@ <% end %>
+
+ + <%= DiseaseCase.human_attribute_name(:burden_body_part) %> + <%= link_to t("search.select_all"), "#", data: { action: "click->checkbox-group#selectAll" } %> + <%= link_to t("search.deselect_all"), "#", data: { action: "click->checkbox-group#deselectAll" } %> + +
+ <% @burden_body_part_options.each do |value| %> + + <% end %> +
+
+ <%= form.label :burden_body_part_text, t("search.burden_body_part_text_label") %> + <%= form.text_field :burden_body_part_text, value: params[:burden_body_part_text], + list: "burden_body_part_datalist", placeholder: t("search.burden_body_part_text_placeholder") %> + + <% @burden_body_part_datalist_options.each do |value| %> + +
+
<%= form.label :decided_on_from, DiseaseCase.human_attribute_name(:decided_on_from) %> @@ -75,6 +110,18 @@ <%= form.date_field :decided_on_to, value: params[:decided_on_to] %>
+
+
+ <%= form.label :application_type, DiseaseCase.human_attribute_name(:application_type) %> + <%= form.select :application_type, + [ [ "— #{t('search.all_results')} —", "" ] ] + @application_type_options.map { |v| [ v, v ] }, + selected: params[:application_type] %> +
+
+ <%= check_box_tag :death_status, "Y", params[:death_status] == "Y" %> + <%= label_tag :death_status, t("search.death_only") %> +
+
<%= t("search.sort_label") %>
diff --git a/test/controllers/disease_cases_controller_test.rb b/test/controllers/disease_cases_controller_test.rb index 4300f72..00bf99e 100644 --- a/test/controllers/disease_cases_controller_test.rb +++ b/test/controllers/disease_cases_controller_test.rb @@ -49,4 +49,23 @@ class DiseaseCasesControllerTest < ActionDispatch::IntegrationTest get search_path, params: { q: "손목" } assert_response :success end + + test "GET /search also applies the main-screen job_name/death_status filters" do + DiseaseCase.create!(case_no: "TEST-SEARCH-MAIN-1", disease_name: "매칭케이스", + job_name: "버스 운전원", death_status: "Y", year: 2024) + DiseaseCase.create!(case_no: "TEST-SEARCH-MAIN-2", disease_name: "제외케이스", + job_name: "조립공", death_status: "N", year: 2024) + + get search_path, params: { job_name: "운전", death_status: "Y" } + + assert_response :success + assert_match "매칭케이스", response.body + assert_no_match(/제외케이스/, response.body) + end + + test "GET /search exposes burden_body_part checkbox/datalist options like the main screen" do + get search_path + assert_response :success + assert_select "datalist#burden_body_part_datalist" + end end From 946dffb4d46861590c1c188484f44cada6cfc11e Mon Sep 17 00:00:00 2001 From: wagurano Date: Thu, 3 Sep 2026 00:13:40 +0900 Subject: [PATCH 12/24] =?UTF-8?q?DiseaseCasesController=20=EC=95=A1?= =?UTF-8?q?=EC=85=98=20=EC=9D=B4=EB=A6=84=EC=9D=84=20=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=ED=8A=B8=20=EA=B4=80=EB=A1=80=EC=97=90=20=EB=A7=9E=EC=B6=B0=20?= =?UTF-8?q?=EB=B0=94=EA=BF=89=EB=8B=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RESTful 관례상 index는 컬렉션의 기본(루트) 목록 액션이어야 하는데, 지금까지는 거꾸로 /search(상세 검색)가 index를, /(메인 화면)가 main을 썼습니다. main을 index로, 기존 index(상세 검색)를 search로 바꿔 라우트와 액션 이름이 일치하도록 정리합니다. 뷰 템플릿(index.html.erb ↔ search.html.erb)과 결과 행 부분 템플릿(index/_disease_case.html.erb ↔ search/_disease_case.html.erb)도 액션 이름에 맞춰 함께 옮겼습니다. root_path/search_path 라우트 헬퍼와 동작은 그대로입니다. --- app/controllers/disease_cases_controller.rb | 12 +- app/views/disease_cases/_case_list.html.erb | 2 +- .../disease_cases/_main_case_list.html.erb | 2 +- app/views/disease_cases/index.html.erb | 82 ++-------- .../index/_disease_case.html.erb | 22 ++- app/views/disease_cases/main.html.erb | 82 ---------- .../disease_cases/main/_disease_case.html.erb | 15 -- app/views/disease_cases/search.html.erb | 140 ++++++++++++++++++ .../search/_disease_case.html.erb | 17 +++ config/routes.rb | 4 +- 10 files changed, 189 insertions(+), 189 deletions(-) delete mode 100644 app/views/disease_cases/main.html.erb delete mode 100644 app/views/disease_cases/main/_disease_case.html.erb create mode 100644 app/views/disease_cases/search.html.erb create mode 100644 app/views/disease_cases/search/_disease_case.html.erb diff --git a/app/controllers/disease_cases_controller.rb b/app/controllers/disease_cases_controller.rb index ac231fb..3b4e54d 100644 --- a/app/controllers/disease_cases_controller.rb +++ b/app/controllers/disease_cases_controller.rb @@ -2,16 +2,16 @@ class DiseaseCasesController < ApplicationController MAX_SEARCH_RESULTS = 500 BURDEN_BODY_PART_CHECKBOX_LIMIT = 12 - # 기존 /search (이전에는 root). 메인 화면(직업·부담 신체 부위·사망 여부·신청서 유형) 필터도 - # 함께 쓸 수 있도록 apply_main_filters를 추가로 적용한다. + # / (메인 화면, 직업·부담 신체 부위·사망 여부·신청서 유형 기반 검색) def index - perform_search(legacy: true) + perform_search(legacy: false) set_main_filter_options end - # 신규 / (메인 화면) - def main - perform_search(legacy: false) + # /search (상세 검색, 이전에는 root였다). 메인 화면 필터도 함께 쓸 수 있도록 + # apply_main_filters를 추가로 적용한다. + def search + perform_search(legacy: true) set_main_filter_options end diff --git a/app/views/disease_cases/_case_list.html.erb b/app/views/disease_cases/_case_list.html.erb index 2a71512..3e0a82b 100644 --- a/app/views/disease_cases/_case_list.html.erb +++ b/app/views/disease_cases/_case_list.html.erb @@ -10,6 +10,6 @@ - <%= render partial: "disease_cases/index/disease_case", collection: cases %> + <%= render partial: "disease_cases/search/disease_case", collection: cases %> diff --git a/app/views/disease_cases/_main_case_list.html.erb b/app/views/disease_cases/_main_case_list.html.erb index c4b4748..954d7dd 100644 --- a/app/views/disease_cases/_main_case_list.html.erb +++ b/app/views/disease_cases/_main_case_list.html.erb @@ -11,6 +11,6 @@ - <%= render partial: "disease_cases/main/disease_case", collection: cases %> + <%= render partial: "disease_cases/index/disease_case", collection: cases %> diff --git a/app/views/disease_cases/index.html.erb b/app/views/disease_cases/index.html.erb index 5698f53..e051a03 100644 --- a/app/views/disease_cases/index.html.erb +++ b/app/views/disease_cases/index.html.erb @@ -1,15 +1,16 @@ -<% content_for :title, t("search.title") %> +<% content_for :title, t("search.main_title") %>
-

<%= t("search.advanced") %>

- <%= form_with url: search_path, method: :get, local: true, data: { controller: "search-form" } do |form| %> +

<%= t("search.main_title") %>

+ <%= form_with url: root_path, method: :get, local: true, data: { controller: "search-form" } do |form| %>
- <%= form.search_field :q, value: params[:q], placeholder: t("search.placeholder") %> + <%= form.search_field :q, value: params[:q], placeholder: t("search.main_placeholder") %> <%= form.submit t("helpers.submit.search"), name: "search", data: { "search-form-target": "button", "loading-text": t("search.loading") } %>

<%= t("search.loading") %>

+
<%= form.label :job_name, DiseaseCase.human_attribute_name(:job_name) %> @@ -20,61 +21,7 @@ <%= form.text_field :job_description, value: params[:job_description] %>
-
- - <%= t("search.search_in_columns") %> - <%= link_to t("search.select_all") , "#", data: { action: "click->checkbox-group#selectAll" } %> - <%= link_to t("search.deselect_all") , "#", data: { action: "click->checkbox-group#deselectAll" } %> - -
- <% DiseaseCases::Searchable::SEARCHABLE_COLUMNS.each do |column| %> - - <% end %> -
-
-
-
- <%= form.label :result, DiseaseCase.human_attribute_name(:result) %> - <%= form.select :result, DiseaseCase.enum_options_for_select_with_blank(:result), { selected: params[:result] } %> -
-
- <%= form.label :year, DiseaseCase.human_attribute_name(:year) %> - <%= form.select :year, years_for_select_with_blank, { selected: params[:year] } %> -
-
-
- - <%= DiseaseCase.human_attribute_name(:disease_category) %> - <%= link_to t("search.select_all") , "#", data: { action: "click->checkbox-group#selectAll" } %> - <%= link_to t("search.deselect_all") , "#", data: { action: "click->checkbox-group#deselectAll" } %> - -
- <% DiseaseCase.enum_options_for_select(:disease_category).each do |label, value| %> - - <% end %> -
-
-
- - <%= DiseaseCase.human_attribute_name(:body_part) %> - <%= link_to t("search.select_all") , "#", data: { action: "click->checkbox-group#selectAll" } %> - <%= link_to t("search.deselect_all") , "#", data: { action: "click->checkbox-group#deselectAll" } %> - -
- <% DiseaseCase.enum_options_for_select(:body_part).each do |label, value| %> - - <% end %> -
-
+
<%= DiseaseCase.human_attribute_name(:burden_body_part) %> @@ -100,16 +47,7 @@
-
-
- <%= form.label :decided_on_from, DiseaseCase.human_attribute_name(:decided_on_from) %> - <%= form.date_field :decided_on_from, value: params[:decided_on_from] %> -
-
- <%= form.label :decided_on_to, DiseaseCase.human_attribute_name(:decided_on_to) %> - <%= form.date_field :decided_on_to, value: params[:decided_on_to] %> -
-
+
<%= form.label :application_type, DiseaseCase.human_attribute_name(:application_type) %> @@ -122,6 +60,7 @@ <%= label_tag :death_status, t("search.death_only") %>
+
<%= t("search.sort_label") %>
@@ -136,5 +75,8 @@
<% end %> - <%= render "search_results" %> + +

<%= link_to t("search.go_to_advanced_search"), search_path %>

+ + <%= render "main_search_results" %> diff --git a/app/views/disease_cases/index/_disease_case.html.erb b/app/views/disease_cases/index/_disease_case.html.erb index 3e90c7f..8aaaa2e 100644 --- a/app/views/disease_cases/index/_disease_case.html.erb +++ b/app/views/disease_cases/index/_disease_case.html.erb @@ -1,17 +1,15 @@ - <%= disease_case.human_attribute_enum(:result) %> - <%= disease_case.human_attribute_enum(:body_part) %> - <%= disease_case.human_attribute_enum(:disease_category) %> + <%= disease_case.job_name.presence || "-" %> + <%= truncate(disease_case.job_description, length: 40) %> + <%= disease_case.application_type.presence || "-" %> + + <% if disease_case.death_status == "Y" %> + <%= t("search.death_badge") %> + <% end %> + <%= link_to disease_case.disease_name, disease_case_path(disease_case) %> + <%= disease_case.human_attribute_enum(:result) %> <%= disease_case.year %>년 - - <%= link_to disease_case.link, target: "_blank", rel: "noopener noreferrer", class: "whitespace-nowrap" do %> - - - - - <% end %> - - \ No newline at end of file + diff --git a/app/views/disease_cases/main.html.erb b/app/views/disease_cases/main.html.erb deleted file mode 100644 index e051a03..0000000 --- a/app/views/disease_cases/main.html.erb +++ /dev/null @@ -1,82 +0,0 @@ -<% content_for :title, t("search.main_title") %> -
-

<%= t("search.main_title") %>

- <%= form_with url: root_path, method: :get, local: true, data: { controller: "search-form" } do |form| %> -
- <%= form.search_field :q, value: params[:q], placeholder: t("search.main_placeholder") %> - <%= form.submit t("helpers.submit.search"), name: "search", data: { "search-form-target": "button", "loading-text": t("search.loading") } %> -
-
-
-

<%= t("search.loading") %>

-
- -
-
- <%= form.label :job_name, DiseaseCase.human_attribute_name(:job_name) %> - <%= form.text_field :job_name, value: params[:job_name] %> -
-
- <%= form.label :job_description, DiseaseCase.human_attribute_name(:job_description) %> - <%= form.text_field :job_description, value: params[:job_description] %> -
-
- -
- - <%= DiseaseCase.human_attribute_name(:burden_body_part) %> - <%= link_to t("search.select_all"), "#", data: { action: "click->checkbox-group#selectAll" } %> - <%= link_to t("search.deselect_all"), "#", data: { action: "click->checkbox-group#deselectAll" } %> - -
- <% @burden_body_part_options.each do |value| %> - - <% end %> -
-
- <%= form.label :burden_body_part_text, t("search.burden_body_part_text_label") %> - <%= form.text_field :burden_body_part_text, value: params[:burden_body_part_text], - list: "burden_body_part_datalist", placeholder: t("search.burden_body_part_text_placeholder") %> - - <% @burden_body_part_datalist_options.each do |value| %> - -
-
- -
-
- <%= form.label :application_type, DiseaseCase.human_attribute_name(:application_type) %> - <%= form.select :application_type, - [ [ "— #{t('search.all_results')} —", "" ] ] + @application_type_options.map { |v| [ v, v ] }, - selected: params[:application_type] %> -
-
- <%= check_box_tag :death_status, "Y", params[:death_status] == "Y" %> - <%= label_tag :death_status, t("search.death_only") %> -
-
- -
- <%= t("search.sort_label") %> -
- - -
-
- <% end %> - -

<%= link_to t("search.go_to_advanced_search"), search_path %>

- - <%= render "main_search_results" %> -
diff --git a/app/views/disease_cases/main/_disease_case.html.erb b/app/views/disease_cases/main/_disease_case.html.erb deleted file mode 100644 index 8aaaa2e..0000000 --- a/app/views/disease_cases/main/_disease_case.html.erb +++ /dev/null @@ -1,15 +0,0 @@ - - <%= disease_case.job_name.presence || "-" %> - <%= truncate(disease_case.job_description, length: 40) %> - <%= disease_case.application_type.presence || "-" %> - - <% if disease_case.death_status == "Y" %> - <%= t("search.death_badge") %> - <% end %> - - - <%= link_to disease_case.disease_name, disease_case_path(disease_case) %> - - <%= disease_case.human_attribute_enum(:result) %> - <%= disease_case.year %>년 - diff --git a/app/views/disease_cases/search.html.erb b/app/views/disease_cases/search.html.erb new file mode 100644 index 0000000..5698f53 --- /dev/null +++ b/app/views/disease_cases/search.html.erb @@ -0,0 +1,140 @@ +<% content_for :title, t("search.title") %> +
+

<%= t("search.advanced") %>

+ <%= form_with url: search_path, method: :get, local: true, data: { controller: "search-form" } do |form| %> +
+ <%= form.search_field :q, value: params[:q], placeholder: t("search.placeholder") %> + <%= form.submit t("helpers.submit.search"), name: "search", data: { "search-form-target": "button", "loading-text": t("search.loading") } %> +
+
+
+

<%= t("search.loading") %>

+
+
+
+ <%= form.label :job_name, DiseaseCase.human_attribute_name(:job_name) %> + <%= form.text_field :job_name, value: params[:job_name] %> +
+
+ <%= form.label :job_description, DiseaseCase.human_attribute_name(:job_description) %> + <%= form.text_field :job_description, value: params[:job_description] %> +
+
+
+ + <%= t("search.search_in_columns") %> + <%= link_to t("search.select_all") , "#", data: { action: "click->checkbox-group#selectAll" } %> + <%= link_to t("search.deselect_all") , "#", data: { action: "click->checkbox-group#deselectAll" } %> + +
+ <% DiseaseCases::Searchable::SEARCHABLE_COLUMNS.each do |column| %> + + <% end %> +
+
+
+
+ <%= form.label :result, DiseaseCase.human_attribute_name(:result) %> + <%= form.select :result, DiseaseCase.enum_options_for_select_with_blank(:result), { selected: params[:result] } %> +
+
+ <%= form.label :year, DiseaseCase.human_attribute_name(:year) %> + <%= form.select :year, years_for_select_with_blank, { selected: params[:year] } %> +
+
+
+ + <%= DiseaseCase.human_attribute_name(:disease_category) %> + <%= link_to t("search.select_all") , "#", data: { action: "click->checkbox-group#selectAll" } %> + <%= link_to t("search.deselect_all") , "#", data: { action: "click->checkbox-group#deselectAll" } %> + +
+ <% DiseaseCase.enum_options_for_select(:disease_category).each do |label, value| %> + + <% end %> +
+
+
+ + <%= DiseaseCase.human_attribute_name(:body_part) %> + <%= link_to t("search.select_all") , "#", data: { action: "click->checkbox-group#selectAll" } %> + <%= link_to t("search.deselect_all") , "#", data: { action: "click->checkbox-group#deselectAll" } %> + +
+ <% DiseaseCase.enum_options_for_select(:body_part).each do |label, value| %> + + <% end %> +
+
+
+ + <%= DiseaseCase.human_attribute_name(:burden_body_part) %> + <%= link_to t("search.select_all"), "#", data: { action: "click->checkbox-group#selectAll" } %> + <%= link_to t("search.deselect_all"), "#", data: { action: "click->checkbox-group#deselectAll" } %> + +
+ <% @burden_body_part_options.each do |value| %> + + <% end %> +
+
+ <%= form.label :burden_body_part_text, t("search.burden_body_part_text_label") %> + <%= form.text_field :burden_body_part_text, value: params[:burden_body_part_text], + list: "burden_body_part_datalist", placeholder: t("search.burden_body_part_text_placeholder") %> + + <% @burden_body_part_datalist_options.each do |value| %> + +
+
+
+
+ <%= form.label :decided_on_from, DiseaseCase.human_attribute_name(:decided_on_from) %> + <%= form.date_field :decided_on_from, value: params[:decided_on_from] %> +
+
+ <%= form.label :decided_on_to, DiseaseCase.human_attribute_name(:decided_on_to) %> + <%= form.date_field :decided_on_to, value: params[:decided_on_to] %> +
+
+
+
+ <%= form.label :application_type, DiseaseCase.human_attribute_name(:application_type) %> + <%= form.select :application_type, + [ [ "— #{t('search.all_results')} —", "" ] ] + @application_type_options.map { |v| [ v, v ] }, + selected: params[:application_type] %> +
+
+ <%= check_box_tag :death_status, "Y", params[:death_status] == "Y" %> + <%= label_tag :death_status, t("search.death_only") %> +
+
+
+ <%= t("search.sort_label") %> +
+ + +
+
+ <% end %> + <%= render "search_results" %> +
diff --git a/app/views/disease_cases/search/_disease_case.html.erb b/app/views/disease_cases/search/_disease_case.html.erb new file mode 100644 index 0000000..3e90c7f --- /dev/null +++ b/app/views/disease_cases/search/_disease_case.html.erb @@ -0,0 +1,17 @@ + + <%= disease_case.human_attribute_enum(:result) %> + <%= disease_case.human_attribute_enum(:body_part) %> + <%= disease_case.human_attribute_enum(:disease_category) %> + + <%= link_to disease_case.disease_name, disease_case_path(disease_case) %> + + <%= disease_case.year %>년 + + <%= link_to disease_case.link, target: "_blank", rel: "noopener noreferrer", class: "whitespace-nowrap" do %> + + + + + <% end %> + + \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 50adcda..f3543a2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,6 +1,6 @@ Rails.application.routes.draw do - root "disease_cases#main" - get "/search", to: "disease_cases#index", as: :search + root "disease_cases#index" + get "/search", to: "disease_cases#search", as: :search get "/about", to: "pages#about", as: :about resources :disease_cases, param: :case_no, only: [ :show ] From 8aa46bcd19723b9d3d44d57075ee51451ab0d331 Mon Sep 17 00:00:00 2001 From: wagurano Date: Thu, 3 Sep 2026 00:15:56 +0900 Subject: [PATCH 13/24] =?UTF-8?q?=EC=84=A4=EA=B3=84=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=EB=A5=BC=20=EC=8B=A4=EC=A0=9C=20=EA=B5=AC=ED=98=84=C2=B7?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EA=B2=B0=EA=B3=BC=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EA=B2=8C=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=ED=95=A9?= =?UTF-8?q?=EB=8B=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - burden_body_part: distinct 토큰 1,174개 실측 후 상위 12개 체크박스 + 자동완성으로 확정된 최종 결정 반영 (3.2절, 6.1절) - DiseaseCase#has_many :disease_case_ksco_codes에 dependent: :delete_all 추가 (코드 리뷰에서 발견된 FK 오류) 반영 (4.2절) - 컨트롤러/concern 코드 스니펫을 실제 구현과 일치하도록 갱신 (5.2절, 5.3절) - 결과 목록에서 KSCO 컬럼 제거, 사망 배지 버그 수정 반영 (8.1절) - /search에 메인 화면 필터 통합 반영 (5.2절, 6.1절, 8.2절) - Phase 1~5 체크리스트를 완료로 표시하고, QA·코드 리뷰 후속 수정을 다루는 Phase 6 신설 (9절) - 리스크 표·결론에 위 변경 사항 정리 (10절, 11절) --- docs/workercare-search.plan.md | 194 +++++++++++++++++++++------------ 1 file changed, 125 insertions(+), 69 deletions(-) diff --git a/docs/workercare-search.plan.md b/docs/workercare-search.plan.md index 3497073..312425e 100644 --- a/docs/workercare-search.plan.md +++ b/docs/workercare-search.plan.md @@ -104,7 +104,8 @@ - `work_relevance_eval`: `["매우_높음", "높음", "보통", "낮음", "매우_낮음", "미흡"]` (`wip/cerebras_prompts.rb`의 추출 스키마 기준 6개 전부 — 4개만 쓰면 `매우_낮음`/`미흡` 데이터를 필터로 찾을 수 없다) - `death_status`: `["Y", "N"]` → boolean enum 처리 가능 - `application_type`: `["요양급여", "유족급여 및 장의비", "요양급여신청서", "유족급여 및 장의비청구서", ...]` (정규화 필요) -- `burden_body_part`/`bad_posture`: 파이프 구분 다중값이므로 단일 컬럼 enum으로 만들지 않는다. 체크박스 목록은 임포트 후 `distinct` 값을 `"|"`로 split해 profiling한 뒤 확정한다. +- `burden_body_part`/`bad_posture`: 파이프 구분 다중값이므로 단일 컬럼 enum으로 만들지 않는다. + > **해결됨 (실 데이터 profiling 결과)**: `burden_body_part`를 실제 CSV(`wip/extract_disease_cases_details_cerebras-ksco.csv`)에서 파이프로 split해 집계한 결과, distinct 원자값이 **1,174개**였다(원본 combined 문자열 기준 7,334개). 빈도 상위 10개가 전체 (행,토큰) 발생의 67.6%, 상위 20개 83.0%, 상위 30개 88.5%를 차지하는 롱테일 분포(우측/좌측/양측 접두어, "어깨"/"견관절" 같은 동의어가 원인)라 전부 체크박스로 노출할 수 없었다. 최종적으로 **상위 12개만 체크박스**(빈도순으로 골라 표시는 가나다순)로 노출하고, 나머지는 새 텍스트 입력 `burden_body_part_text`에 네이티브 HTML ``(전체 distinct 토큰을 `