Created
June 10, 2026 03:22
-
-
Save wagurano/a56962a68b063b55f83f76c82033a547 to your computer and use it in GitHub Desktop.
업무상 질병 판정서에서 항목 추출
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| require 'csv' | |
| require 'json' | |
| require 'net/http' | |
| require 'set' | |
| require 'time' | |
| OLLAMA_HOST = ENV.fetch('OLLAMA_HOST', 'localhost') | |
| OLLAMA_PORT = ENV.fetch('OLLAMA_PORT', 11434).to_i | |
| OLLAMA_MODEL = ENV.fetch('OLLAMA_MODEL', 'gemma4:latest') | |
| OLLAMA_PATH = '/api/chat' | |
| MAX_RETRIES = ENV.fetch('MAX_RETRIES', 3).to_i | |
| COOLDOWN = ENV.fetch('COOLDOWN', 0.2).to_f | |
| LIMIT = ENV.fetch('LIMIT', 0).to_i | |
| OUTPUT_PATH = ENV.fetch('OUTPUT_PATH', 'test_v4.csv') | |
| ERROR_LOG = ENV.fetch('ERROR_LOG', 'test_errors_v4.log') | |
| MAIN_HEADERS = %w[ | |
| case_no | |
| 성별 | |
| 발병_당시_연령 | |
| 신장 | |
| 체중 | |
| BMI_지수 | |
| 우세손 | |
| 흡연_여부 | |
| 총_흡연량 | |
| 음주_여부 | |
| 주당_음주_횟수 | |
| 기저질환_보유 | |
| 과거_산재_처리_이력 | |
| 직종명 | |
| 고용_형태 | |
| 근무_형태 | |
| 현_직종_총_종사기간 | |
| 1주_평균_근무시간 | |
| 1일_평균_근무시간 | |
| 부담_신체_부위 | |
| 주요_부적절한_자세 | |
| 중량물_취급_여부 | |
| 취급_물품_최대_무게 | |
| 1일_취급_총_누적_중량 | |
| 기타_유해요인_노출 | |
| 최초_신청_상병명 | |
| 최종_의학적_확인_상병명 | |
| 의학영상_MRI_등_주요_소견 | |
| 상병명_변경_인정_여부 | |
| 업무관련성_평가 | |
| 업무부담_가중요인_노출 | |
| 최종_산재_인정_여부 | |
| 판단_주요_근거 | |
| ] | |
| COLUMN_PROMPTS = { | |
| 'medical_records' => { | |
| 'fields' => %w[성별 발병_당시_연령 신장 체중 BMI_지수 우세손 흡연_여부 총_흡연량 음주_여부 주당_음주_횟수 기저질환_보유 과거_산재_처리_이력 최초_신청_상병명 최종_의학적_확인_상병명 의학영상_MRI_등_주요_소견 상병명_변경_인정_여부], | |
| 'instruction' => '환자 진료기록. 환자의 기본 신체 정보, 생활습관, 기저질환, 상병 및 진단 정보를 추출.', | |
| 'schema' => <<~SCHEMA, | |
| - "성별": "남" 또는 "여" | |
| - "발병_당시_연령": 숫자 (세) | |
| - "신장": 숫자 (cm) | |
| - "체중": 숫자 (kg) | |
| - "BMI_지수": 숫자 | |
| - "우세손": "오른손", "왼손", "양손" 중 하나 | |
| - "흡연_여부": "현재_흡연", "과거_흡연", "비흡연" 중 하나 | |
| - "총_흡연량": 숫자 (갑년, 없으면 null) | |
| - "음주_여부": "음주" 또는 "비음주" | |
| - "주당_음주_횟수": 숫자 (없으면 null) | |
| - "기저질환_보유": 질병 이름 배열 (없으면 null) | |
| - "과거_산재_처리_이력": "Y" 또는 "N" | |
| - "최초_신청_상병명": 질병 이름 배열 (없으면 null) | |
| - "최종_의학적_확인_상병명": 최초와 동일한 목록 (없으면 null) | |
| - "의학영상_MRI_등_주요_소견": 의학적 의견 배열(없으면 null) | |
| - "상병명_변경_인정_여부": "Y" 또는 "N" | |
| SCHEMA | |
| 'example' => '{"성별": "남", "발병_당시_연령": 52, "신장": 170, "체중": 75, "BMI_지수": 25.95, "우세손": "오른손", "흡연_여부": "비흡연", "총_흡연량": null, "음주_여부": "비음주", "주당_음주_횟수": null, "기저질환_보유": ["고혈압"], "과거_산재_처리_이력": "N", "최초_신청_상병명": ["뇌출혈"], "최종_의학적_확인_상병명": ["뇌출혈"], "의학영상_MRI_등_주요_소견": ["특이소견_없음"], "상병명_변경_인정_여부": "N"}' | |
| }, | |
| 'recognized_facts' => { | |
| 'fields' => %w[직종명 고용_형태 근무_형태 현_직종_총_종사기간 1주_평균_근무시간 1일_평균_근무시간 부담_신체_부위 주요_부적절한_자세 중량물_취급_여부 취급_물품_최대_무게 1일_취급_총_누적_중량 기타_유해요인_노출], | |
| 'instruction' => '산업재해 위원회 인정한 사실. 근로자의 직업력, 근무 조건, 작업 내용, 신체부담 및 유해요인 정보를 추출.', | |
| 'schema' => <<~SCHEMA, | |
| - "직종명": 직업 종류 이름 | |
| - "고용_형태": 일하는 형태 | |
| - "근무_형태": 교대 또는 근무 시간 등에 대한 형태 | |
| - "현_직종_총_종사기간": 숫자 (개월) | |
| - "1주_평균_근무시간": 숫자 (시간) | |
| - "1일_평균_근무시간": 숫자 (시간) | |
| - "부담_신체_부위": 신체 부위 배열 | |
| - "주요_부적절한_자세": 자세 배열 | |
| - "중량물_취급_여부": "Y" 또는 "N" | |
| - "취급_물품_최대_무게": 숫자 (kg) | |
| - "1일_취급_총_누적_중량": 숫자 (kg) | |
| - "기타_유해요인_노출": 그 밖의 원인이 되는 상황 배열 (없으면 null) | |
| SCHEMA | |
| 'example' => '{"직종명": "환경미화원", "고용_형태": "일용직", "근무_형태": "고정주간근무", "현_직종_총_종사기간": 36, "1주_평균_근무시간": 40, "1일_평균_근무시간": 8, "부담_신체_부위": null, "주요_부적절한_자세": null, "중량물_취급_여부": "Y", "취급_물품_최대_무게": 20, "1일_취급_총_누적_중량": 500, "기타_유해요인_노출": null}' | |
| }, | |
| 'committee_decision' => { | |
| 'fields' => %w[업무관련성_평가 업무부담_가중요인_노출 최종_산재_인정_여부 판단_주요_근거], | |
| 'instruction' => '산업재해 보상 위원회의 판정 결정문. 업무관련성 평가, 최종 인정 여부, 판단 근거를 추출.', | |
| 'schema' => <<~SCHEMA, | |
| - "업무관련성_평가": "매우_높음", "높음", "보통", "낮음", "매우_낮음", "미흡" 중 하나 | |
| - "업무부담_가중요인_노출": 더 부담이 되는 원인 배열 (없으면 null) | |
| - "최종_산재_인정_여부": "인정", "불인정", "일부인정", "변경인정" 중 하나 | |
| - "판단_주요_근거": 근거 배열 (없으면 null) | |
| SCHEMA | |
| 'example' => '{"업무관련성_평가": "낮음", "업무부담_가중요인_노출": ["해당없음"], "최종_산재_인정_여부": "불인정", "판단_주요_근거": ["개인_기저질환_악화", "객관적_직업력_부족"]}' | |
| } | |
| } | |
| NORMALIZATION_RULES = { | |
| '과거_산재_처리_이력' => { /있음|yes|y/i => 'Y', /없음|no|n/i => 'N' }, | |
| '중량물_취급_여부' => { /있음|yes|y/i => 'Y', /없음|no|n/i => 'N' }, | |
| '업무관련성_평가' => { /매우_?높음/ => '매우_높음', /높음/ => '높음', /보통/ => '보통', /매우_?낮음/ => '매우_낮음', /낮음/ => '낮음', /미흡/ => '미흡' }, | |
| '최종_산재_인정_여부' => { /불인정|불승인|미인정|인정.*되지/ => '불인정', /일부인정|일부/ => '일부인정', /인정|승인/ => '인정', /변경/ => '변경인정' } | |
| } | |
| class Extractor | |
| def run | |
| input_file_name = "disease_cases_details.csv" | |
| output_file_name = "test_v4.csv" | |
| check_ollama! | |
| ensure_headers(output_file_name, MAIN_HEADERS) | |
| done = completed_case_nos(output_file_name) | |
| total = [ File.foreach(input_file_name).count - 1, 1 ].max | |
| warn "Resuming: #{done.size}/#{total} already done." if done.size > 0 | |
| total_done = done.size | |
| processed = 0 | |
| CSV.foreach(input_file_name, headers: true) do |row| | |
| next if done.include?(row['case_no']) | |
| break if LIMIT > 0 && processed >= LIMIT | |
| case_no = row['case_no'] | |
| data = {} | |
| http = Net::HTTP.new("localhost", 11434) | |
| http.read_timeout = 300 | |
| http.open_timeout = 30 | |
| http.start | |
| %w[medical_records recognized_facts committee_decision].each do |col| | |
| text = row[col] | |
| next if text.nil? || text.strip.empty? | |
| result = extract_column(text, col, case_no, http) | |
| data.merge!(result) | |
| sleep(COOLDOWN) | |
| end | |
| CSV.open(output_file_name, 'a') do |csv| | |
| csv << MAIN_HEADERS.map do |h| | |
| if h == 'case_no' | |
| case_no | |
| else | |
| case data[h] | |
| when nil then '' | |
| when Array then data[h].join('|') | |
| else data[h].to_s | |
| end | |
| end | |
| end | |
| end | |
| processed += 1 | |
| pct = ((done.size + processed) * 100.0 / total).round(1) | |
| warn "[#{Time.now.strftime("%Y-%m-%d %H:%M:%S")}] #{processed} / #{LIMIT} (#{(processed / LIMIT.to_f * 100.0).round(1)}% #{pct}% total)" | |
| http&.finish if http&.started? | |
| sleep(100) | |
| ensure | |
| http&.finish if http&.started? | |
| end | |
| total_done += processed | |
| warn "Done. #{processed} new cases. Total output: #{total_done}." | |
| warn "Errors → #{ERROR_LOG}" if File.exist?(ERROR_LOG) && File.size(ERROR_LOG) > 0 | |
| end | |
| private | |
| def check_ollama! | |
| http = Net::HTTP.new(OLLAMA_HOST, OLLAMA_PORT) | |
| http.open_timeout = 5 | |
| http.get('/api/tags') | |
| warn "Ollama OK (#{OLLAMA_HOST}:#{OLLAMA_PORT}, model: #{OLLAMA_MODEL})" | |
| rescue => e | |
| abort "Ollama not reachable at #{OLLAMA_HOST}:#{OLLAMA_PORT} — #{e.message}\nStart with: ollama serve" | |
| end | |
| def call_api(prompt, http) | |
| body = JSON.dump( | |
| model: OLLAMA_MODEL, | |
| messages: [ { role: 'user', content: prompt } ], | |
| stream: false | |
| ) | |
| req = Net::HTTP::Post.new(OLLAMA_PATH, 'Content-Type' => 'application/json') | |
| req.body = body | |
| res = http.request(req) | |
| unless res.is_a?(Net::HTTPSuccess) | |
| raise "Ollama HTTP #{res.code}: #{res.body[0, 200]}" | |
| end | |
| parsed = JSON.parse(res.body) | |
| parsed.dig('message', 'content') || | |
| raise("Unexpected response shape: #{res.body[0, 200]}") | |
| end | |
| def extract_column(text, col, case_no, http) | |
| col_prompt= COLUMN_PROMPTS[col] | |
| prompt = <<~PROMPT | |
| #{col_prompt['instruction']} | |
| [규칙] | |
| 1. 텍스트에서 명시적으로 확인되지 않거나 추정할 수 없는 경우, 반드시 null 사용. | |
| 2. 배열 타입은 JSON 배열 []로 출력. 값이 없으면 null 사용. | |
| 3. 아래에 명시된 필드만 추출. 다른 필드는 추가 금지. | |
| 4. JSON 형식으로만 응답하. 추가 설명 없이 순수 JSON만 출력. | |
| [추출할 필드] | |
| #{col_prompt['schema']} | |
| [JSON 예시] | |
| #{col_prompt['example']} | |
| [텍스트] | |
| #{text} | |
| PROMPT | |
| retries = 0 | |
| begin | |
| response = call_api(prompt, http) | |
| data = extract_json(response, case_no) | |
| if data | |
| fields = COLUMN_PROMPTS[col]['fields'] | |
| data = data.select { |k, _| fields.include?(k) } | |
| data = normalize(data, col) | |
| return data | |
| end | |
| rescue Net::ReadTimeout, Net::OpenTimeout => e | |
| warn "Timeout #{case_no}/#{col} (try #{retries}): #{e.message}" | |
| log_error(case_no, 'TIMEOUT', "#{col}: #{e.message}") | |
| sleep(15 * (retries + 1)) if (retries += 1) < MAX_RETRIES | |
| retry if retries < MAX_RETRIES | |
| rescue => e | |
| warn "Error #{case_no}/#{col} (try #{retries}): #{e.message}" | |
| log_error(case_no, 'ERROR', "#{col}: #{e.message}") | |
| sleep(5) if (retries += 1) < MAX_RETRIES | |
| retry if retries < MAX_RETRIES | |
| end | |
| {} | |
| end | |
| def extract_json(response, case_no = nil) | |
| json_str = if response =~ /```(?:json)?\s*(.*?)\s*```/m | |
| $1.strip | |
| else | |
| s = response.index('{') | |
| e = response.rindex('}') | |
| return nil if s.nil? || e.nil? | |
| response[s..e] | |
| end | |
| JSON.parse(json_str) | |
| rescue => e | |
| warn "JSON parse error (#{case_no}): #{e.message}" | |
| log_error(case_no, 'JSON_PARSE_ERROR', e.message) if case_no | |
| nil | |
| end | |
| def normalize(data, col) | |
| COLUMN_PROMPTS[col]['fields'].each do |field| | |
| next unless data.key?(field) | |
| val = data[field] | |
| next if val.nil? | |
| if (rules = NORMALIZATION_RULES[field]) | |
| if val.is_a?(String) | |
| matched = rules.find { |re, _| val.match?(re) } | |
| data[field] = matched ? matched[1] : nil | |
| elsif val.is_a?(Array) | |
| data[field] = val.map do |item| | |
| next item unless item.is_a?(String) | |
| matched = rules.find { |re, _| item.match?(re) } | |
| matched ? matched[1] : item | |
| end.uniq | |
| end | |
| end | |
| if %w[현_직종_총_종사기간 1주_평균_근무시간 1일_평균_근무시간 취급_물품_최대_무게 1일_취급_총_누적_중량 총_흡연량 주당_음주_횟수 발병_당시_연령 신장 체중 BMI_지수].include?(field) | |
| if val.is_a?(String) | |
| data[field] = val.match?(/^\d+(\.\d+)?$/) ? (val.include?('.') ? val.to_f : val.to_i) : nil | |
| end | |
| end | |
| end | |
| data | |
| end | |
| def completed_case_nos(filename) | |
| return Set.new unless File.exist?(filename) && File.size(filename) > 0 | |
| completed = Set.new | |
| CSV.foreach(filename, headers: true) { completed << it["case_no"] if it["case_no"] && it["case_no"].length > 0 } | |
| completed | |
| end | |
| def ensure_headers(path, headers) | |
| return if File.exist?(path) && File.size(path) > 0 | |
| CSV.open(path, 'w') { |csv| csv << headers } | |
| end | |
| def log_error(case_no, type, msg) | |
| File.open(ERROR_LOG, 'a') { |f| f.puts "#{Time.now.iso8601}\t#{case_no}\t#{type}\t#{msg.gsub(/[\t\n]/, ' ')}" } | |
| end | |
| def count_total_cases(path) | |
| n = File.foreach(path).count - 1 | |
| n < 1 ? 1 : n | |
| end | |
| end | |
| Extractor.new.run |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment