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..e86f3ae --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +ruby 3.4.9 diff --git a/Dockerfile b/Dockerfile index 334910c..efd4393 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,8 +7,8 @@ # For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html -# Make sure RUBY_VERSION matches the Ruby version in .ruby-version -ARG RUBY_VERSION=4.0.6 +# Make sure RUBY_VERSION matches the Ruby version in .tool-versions +ARG RUBY_VERSION=3.4.9 FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base # Rails app lives here @@ -32,16 +32,9 @@ FROM base AS build # Install packages needed to build gems RUN apt-get update -qq && \ - apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config libssl-dev libclang-dev && \ + apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config libssl-dev && \ rm -rf /var/lib/apt/lists /var/cache/apt/archives -# Install Rust toolchain (solid_mcp's native extension is built from source; no -# precompiled binary gem supports this Ruby version yet) -ENV RUSTUP_HOME=/usr/local/rustup \ - CARGO_HOME=/usr/local/cargo \ - PATH=/usr/local/cargo/bin:$PATH -RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable - # Install application gems COPY Gemfile Gemfile.lock vendor ./ 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..9bdf780 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 @@ -399,6 +403,21 @@ GEM concurrent-ruby (~> 1.0) railties (>= 8.0) rb_sys (~> 0.9) + solid_mcp (0.5.0-arm64-darwin) + activejob (>= 8.0) + activerecord (>= 8.0) + concurrent-ruby (~> 1.0) + railties (>= 8.0) + solid_mcp (0.5.0-x86_64-linux) + activejob (>= 8.0) + activerecord (>= 8.0) + concurrent-ruby (~> 1.0) + railties (>= 8.0) + solid_mcp (0.5.0-x86_64-linux-musl) + activejob (>= 8.0) + activerecord (>= 8.0) + concurrent-ruby (~> 1.0) + railties (>= 8.0) solid_queue (1.4.0) activejob (>= 7.1) activerecord (>= 7.1) @@ -485,6 +504,7 @@ DEPENDENCIES importmap-rails jbuilder kamal + overmind pagy (~> 43.5) propshaft puma (>= 5.0) @@ -593,6 +613,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 @@ -636,6 +660,9 @@ CHECKSUMS securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41 solid_mcp (0.5.0) sha256=6ccc57e9a12f8c2f5d6c62e7d29252cae1b63f326a128a97e47a48c420df3467 + solid_mcp (0.5.0-arm64-darwin) sha256=f4000b98b5d08ec7674c5b9fa6167269ebf7477d02fc095dcf11890d166649dc + solid_mcp (0.5.0-x86_64-linux) sha256=b305c44a9bf3c1074e17b090919d2635752692501ef5200966e5c2be90cf03eb + solid_mcp (0.5.0-x86_64-linux-musl) sha256=65f54897047abd261e7126c390e0af0240972287b84c39e14afe058b88760a53 solid_queue (1.4.0) sha256=e6a18d196f0b27cb6e3c77c5b31258b05fb634f8ed64fb1866ed164047216c2a sqlite3 (2.9.2-aarch64-linux-gnu) sha256=eeb86db55645b85327ba75129e3614658d974bf4da8fdc87018a0d42c59f6e42 sqlite3 (2.9.2-aarch64-linux-musl) sha256=4feff91fb8c2b13688da34b5627c9d1ed9cedb3ee87a7114ec82209147f07a6d diff --git a/README.md b/README.md index b31b43c..2b748ac 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ | 항목 | 버전 / 도구 | |------|------------| -| Ruby | 4.0.6 | +| Ruby | 3.4.9 | | Rails | 8.1 | | 데이터베이스 | SQLite3 (FTS5 전문 검색) | | MCP 서버 | actionmcp + Falcon | @@ -50,13 +50,28 @@ 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 /disease_cases/:id` | 판정서 상세 | +| `GET /` | 메인 검색 (직업·부담 신체 부위·사망여부·신청서유형 필터 + 전문 검색) — 간단한 검색 화면 | +| `GET /search` | 상세 검색 (메인 화면의 모든 필터 + 심의결과·질병분류·신체부위·판정일·근무 형태·업무관련성 평가 필터 + 전문 검색) | +| `GET /disease_cases/:case_no` | 판정서 상세 | + +`/search`는 메인 화면(`/`)의 구조화 필터를 전부 포함해서 더 넓게 검색할 수 있습니다. 다만 고용 형태·KSCO 코드는 사용자가 직접 값을 골라 검색할 이유가 적어(고용 형태는 표현이 1,500개 넘게 잘게 쪼개져 있고, KSCO 코드는 숫자 코드) 두 화면 어디에도 입력 필드는 없습니다 — 쿼리스트링이나 MCP 클라이언트로는 계속 필터링할 수 있습니다. ## MCP 서버 @@ -71,7 +86,7 @@ https://..../mcp | Tool | 설명 | |------|------| -| `search_disease_cases` | 판정서 전문 검색 + 통계 집계 | +| `search_disease_cases` | 판정서 전문 검색(`q`, 선택) + 직업/사망여부/KSCO 코드 등 구조화 필터 + 통계 집계. `q`와 구조화 필터가 전부 비어있으면 에러를 반환합니다(전체 판정서를 검색 결과처럼 반환하지 않음) | | `compare_approval_factors` | 인정/불인정 사례 패턴 비교 | | `suggest_evidence` | 필요 증거 자료 제안 (룰 기반) | | `get_procedure_guide` | 산재 신청 절차 안내 | @@ -119,6 +134,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 직업분류 연동 설계 ## 라이선스 diff --git a/app/controllers/disease_cases_controller.rb b/app/controllers/disease_cases_controller.rb index 4619bb2..f62d653 100644 --- a/app/controllers/disease_cases_controller.rb +++ b/app/controllers/disease_cases_controller.rb @@ -1,8 +1,26 @@ class DiseaseCasesController < ApplicationController MAX_SEARCH_RESULTS = 500 + BURDEN_BODY_PART_CHECKBOX_LIMIT = 12 + # wip/cerebras_prompts.rb 추출 스키마상 허용값 6개 전부 (docs/workercare-search.plan.md 3.2절) — + # DB에도 이 6개만 존재하는 진짜 enum이라 distinct pluck 대신 고정 목록을 순서대로 노출한다. + WORK_RELEVANCE_EVAL_OPTIONS = %w[매우_높음 높음 보통 낮음 매우_낮음 미흡].freeze + # / (메인 화면). 간단한 검색을 위해 직업·부담 신체 부위·사망 여부·신청서 유형만 노출한다 — + # 근무형태/업무관련성은 /search(상세 검색)에서만 노출한다. 고용형태·KSCO 코드는 사용자가 + # 직접 골라 검색할 이유가 없어(고용형태는 값이 너무 잘게 쪼개져 있고, KSCO 코드는 숫자다) + # 웹 UI에는 아예 없다. apply_main_filters/main_search_params는 두 값 모두 그대로 받아서 + # MCP 클라이언트(직업 설명 → 고용형태/KSCO 코드 매핑)는 계속 쓸 수 있다. def index - perform_search + perform_search(legacy: false) + set_common_filter_options + end + + # /search (상세 검색, 이전에는 root였다). 메인 화면 필터도 함께 쓸 수 있도록 + # apply_main_filters를 추가로 적용한다. + def search + perform_search(legacy: true) + set_common_filter_options + set_advanced_filter_options end def show @@ -11,14 +29,54 @@ def show private - def perform_search - @scope, @fallback = DiseaseCase.search(search_params) + def perform_search(legacy:) + 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_common_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 + + def set_advanced_filter_options + @work_relevance_eval_options = WORK_RELEVANCE_EVAL_OPTIONS + end + + # 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(counts) + counts.sort_by { |_, count| -count } + .first(BURDEN_BODY_PART_CHECKBOX_LIMIT).map(&:first).sort + end + + def burden_body_part_datalist_options(counts) + counts.keys.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 +95,16 @@ 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_text, + 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..058a260 100644 --- a/app/mcp/tools/search_disease_cases_tool.rb +++ b/app/mcp/tools/search_disease_cases_tool.rb @@ -9,8 +9,9 @@ class SearchDiseaseCasesTool < ApplicationMCPTool property :q, type: "string", - description: "Natural language search query extracted from user question", - required: true + description: "Natural language search query extracted from user question. Optional — " \ + "omit it to search using only the structured filters below (e.g. death_status, ksco_code, job_name).", + required: false collection :search_in, type: "string", @@ -43,9 +44,38 @@ 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 + if q.blank? && no_structured_filters? + return render structured: { + error: "q(검색어) 또는 구조화 필터(job_name/job_description/death_status/ksco_code/" \ + "disease_category/body_part/decided_on_from/decided_on_to) 중 최소 하나는 지정해야 합니다.", + data: nil + } + end + 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? @@ -93,6 +123,19 @@ def perform private + # q도 없고 구조화 필터도 하나도 없으면 DiseaseCase.search(q: nil)가 스코프 필터 없이 전체 + # 코퍼스(6만여 건)를 반환한다 — LLM이 사용자 발화에서 필터를 하나도 못 뽑아낸 경우 이걸 + # 유효한 검색 결과인 것처럼 confidence_score까지 붙여 내보내는 회귀가 있었다(코드 리뷰에서 + # 발견·재현 확인). limit은 필터가 아니라 페이지네이션 옵션이라 여기서 세지 않는다. + def no_structured_filters? + Array(search_in).reject(&:blank?).empty? && + Array(disease_category).reject(&:blank?).empty? && + Array(body_part).reject(&:blank?).empty? && + decided_on_from.blank? && decided_on_to.blank? && + job_name.blank? && job_description.blank? && + death_status.blank? && Array(ksco_code).reject(&:blank?).empty? + end + def build_search_params params = { q: q } params[:search_in] = Array(search_in).reject(&:blank?) if search_in.present? @@ -109,8 +152,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 +214,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 +233,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 +250,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 +260,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..af9eb78 --- /dev/null +++ b/app/models/concerns/disease_cases/main_searchable.rb @@ -0,0 +1,136 @@ +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? || 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 ?)" + }.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? + # employment_type은 distinct 값이 1,583개로 잘게 쪼개져 있어(예: "1년 계약직"/ + # "1년 계약직(비정규직)") 웹 UI에는 입력을 두지 않는다(사용자가 직접 골라 검색할 이유가 + # 없음). MCP 클라이언트가 구조화 필터로 넘길 때만 exact match로 쓰인다. + scope = scope.where(employment_type: params[:employment_type]) if params[:employment_type].present? + + # work_type은 distinct 값이 14,582개로(예: "02:30~11:30 (평일 및 토요일)") 사실상 자유 텍스트라 + # 로 목록화할 수 없다(3.2절). exact match를 쓰면 사용자가 직접 입력한 값이 저장된 + # 문자열과 한 글자라도 다르면 0건이 되므로, job_name/job_description과 같은 LIKE 부분 일치로 처리한다. + if params[:work_type].present? + safe = sanitize_sql_like(params[:work_type]) + scope = scope.where("work_type LIKE ?", "%#{safe}%") + end + + 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..f25615d 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, dependent: :delete_all + 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/_case_list.html.erb b/app/views/disease_cases/_case_list.html.erb index 2a71512..24916d7 100644 --- a/app/views/disease_cases/_case_list.html.erb +++ b/app/views/disease_cases/_case_list.html.erb @@ -1,15 +1,21 @@ + + + + + + + - - <%= render partial: "disease_cases/index/disease_case", collection: cases %> + <%= render partial: "disease_cases/search/disease_case", collection: cases %>
<%= DiseaseCase.human_attribute_name(:link) %><%= DiseaseCase.human_attribute_name(:job_name) %><%= DiseaseCase.human_attribute_name(:job_description) %><%= DiseaseCase.human_attribute_name(:application_type) %><%= DiseaseCase.human_attribute_name(:work_type) %><%= DiseaseCase.human_attribute_name(:work_relevance_eval) %><%= DiseaseCase.human_attribute_name(:death_status) %> <%= DiseaseCase.human_attribute_name(:result) %> <%= DiseaseCase.human_attribute_name(:body_part) %> <%= DiseaseCase.human_attribute_name(:disease_category) %> <%= DiseaseCase.human_attribute_name(:disease_name) %> <%= DiseaseCase.human_attribute_name(:year) %><%= DiseaseCase.human_attribute_name(:link) %>
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..cdb6858 --- /dev/null +++ b/app/views/disease_cases/_main_case_list.html.erb @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + <%= render partial: "disease_cases/index/disease_case", collection: cases %> + +
<%= DiseaseCase.human_attribute_name(:link) %><%= DiseaseCase.human_attribute_name(:job_name) %><%= DiseaseCase.human_attribute_name(:job_description) %><%= DiseaseCase.human_attribute_name(:application_type) %><%= DiseaseCase.human_attribute_name(:work_type) %><%= DiseaseCase.human_attribute_name(:work_relevance_eval) %><%= DiseaseCase.human_attribute_name(:death_status) %><%= DiseaseCase.human_attribute_name(:disease_name) %><%= DiseaseCase.human_attribute_name(:result) %><%= DiseaseCase.human_attribute_name(:year) %>
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..b77f543 100644 --- a/app/views/disease_cases/index.html.erb +++ b/app/views/disease_cases/index.html.erb @@ -1,80 +1,66 @@ -<% content_for :title, t("search.title") %> +<% content_for :title, t("search.main_title") %>
-

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

+

<%= 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") %>

-
- - <%= 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 :job_name, DiseaseCase.human_attribute_name(:job_name) %> + <%= form.text_field :job_name, value: params[:job_name] %>
- <%= form.label :year, DiseaseCase.human_attribute_name(:year) %> - <%= form.select :year, years_for_select_with_blank, { selected: params[:year] } %> + <%= form.label :job_description, DiseaseCase.human_attribute_name(:job_description) %> + <%= form.text_field :job_description, value: params[:job_description] %>
+
- <%= 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.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" } %> -
- <% DiseaseCase.enum_options_for_select(:disease_category).each do |label, value| %> +
+ <% @burden_body_part_options.each do |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 %> +
+ <%= 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 :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] %>
-
- <%= form.label :decided_on_to, DiseaseCase.human_attribute_name(:decided_on_to) %> - <%= form.date_field :decided_on_to, value: params[:decided_on_to] %> +
+ <%= check_box_tag :death_status, "Y", params[:death_status] == "Y" %> + <%= label_tag :death_status, t("search.death_only") %>
+
<%= t("search.sort_label") %>
@@ -89,5 +75,6 @@
<% end %> - <%= render "search_results" %> + + <%= 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..2cdb492 100644 --- a/app/views/disease_cases/index/_disease_case.html.erb +++ b/app/views/disease_cases/index/_disease_case.html.erb @@ -1,11 +1,4 @@ - <%= 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 %> @@ -14,4 +7,19 @@ <% end %> - \ No newline at end of file + <%= disease_case.job_name.presence || "-" %> + <%= truncate(disease_case.job_description, length: 40) %> + <%= disease_case.application_type.presence || "-" %> + <%= truncate(disease_case.work_type, length: 40) %> + <%= disease_case.work_relevance_eval.presence&.tr("_", " ") || "-" %> + + <% 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..e59ee8b --- /dev/null +++ b/app/views/disease_cases/search.html.erb @@ -0,0 +1,152 @@ +<% 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") %> +
+
+
+
+ <%= form.label :work_type, DiseaseCase.human_attribute_name(:work_type) %> + <%= form.text_field :work_type, value: params[:work_type] %> +
+
+ <%= form.label :work_relevance_eval, DiseaseCase.human_attribute_name(:work_relevance_eval) %> + <%= form.select :work_relevance_eval, + [ [ "— #{t('search.all_results')} —", "" ] ] + @work_relevance_eval_options.map { |v| [ v.tr("_", " "), v ] }, + selected: params[:work_relevance_eval] %> +
+
+
+ <%= 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..a71179a --- /dev/null +++ b/app/views/disease_cases/search/_disease_case.html.erb @@ -0,0 +1,27 @@ + + + <%= link_to disease_case.link, target: "_blank", rel: "noopener noreferrer", class: "whitespace-nowrap" do %> + + + + + <% end %> + + <%= disease_case.job_name.presence || "-" %> + <%= truncate(disease_case.job_description, length: 40) %> + <%= disease_case.application_type.presence || "-" %> + <%= truncate(disease_case.work_type, length: 40) %> + <%= disease_case.work_relevance_eval.presence&.tr("_", " ") || "-" %> + + <% if disease_case.death_status == "Y" %> + <%= t("search.death_badge") %> + <% end %> + + <%= 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 %>년 + \ No newline at end of file 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..17651ed 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -26,3 +26,9 @@ 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... + 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 diff --git a/config/locales/ko.yml b/config/locales/ko.yml index 524ddb8..b53e62f 100644 --- a/config/locales/ko.yml +++ b/config/locales/ko.yml @@ -26,3 +26,9 @@ ko: sort_recent: 최신순 sort_relevance: 관련도순 title: 업무상질병 판정서 검색 + main_title: 직업·신체 부위로 판정서 찾기 + main_placeholder: 직업, 업무, 판단 근거로 검색... + burden_body_part_text_label: 직접 검색 + burden_body_part_text_placeholder: "예: 견관절, 요추" + death_only: 사망 사례만 보기 + death_badge: 사망 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..f3543a2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,8 +1,9 @@ Rails.application.routes.draw do 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: [ :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 149fc7f..6b347d3 100644 --- a/docs/workercare-search.plan.md +++ b/docs/workercare-search.plan.md @@ -74,35 +74,38 @@ | 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으로 만들지 않는다. + > **해결됨 (실 데이터 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 토큰을 `