|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +Compute LLM-based ranking indices per jargon using OpenRouter via LangChain. |
| 4 | +
|
| 5 | +Inputs: |
| 6 | + - jargon.csv (must contain at least: id, name, slug) |
| 7 | + - translation.csv (must contain at least: id, name, jargon_id) |
| 8 | +
|
| 9 | +Output: |
| 10 | + - llm_ranks.csv with columns: translation_id, llm_rank |
| 11 | +
|
| 12 | +Environment: |
| 13 | + - AI_API_KEY: API key for OpenRouter |
| 14 | +
|
| 15 | +Notes: |
| 16 | + - We do not mutate any database here; this is offline ranking. |
| 17 | + - Newly added translations after a run should default to NULL in DB until next batch. |
| 18 | + - Resumable: if llm_ranks.csv exists, skip GPT calls for any group whose translations |
| 19 | + are already fully ranked; write progress after each processed group. |
| 20 | +""" |
| 21 | + |
| 22 | +import os |
| 23 | +import sys |
| 24 | +import time |
| 25 | +import argparse |
| 26 | +import re |
| 27 | +import pandas as pd |
| 28 | +from tqdm import tqdm |
| 29 | +from typing import List, Tuple, Dict |
| 30 | +from dotenv import load_dotenv |
| 31 | + |
| 32 | +from langchain_openai import ChatOpenAI |
| 33 | +from langchain_core.messages import HumanMessage, SystemMessage |
| 34 | + |
| 35 | + |
| 36 | +SYSTEM_PROMPT = """ |
| 37 | +컴퓨터과학 및 컴퓨터공학 분야의 전문용어를 쉽게 번역하는 것의 취지는 다음과 같아야 한다: |
| 38 | +--- |
| 39 | +# 배경 |
| 40 | +전문지식이 전문가들에게만 머문다면 그 분야는 그렇게 쇠퇴할 수 있다. 저변이 좁아지고 깊은 공부를 달성하는 인구는 그만큼 쪼그라들 수 있다. |
| 41 | +전문지식이 보다 많은 사람들에게 널리 퍼진다면, 그래서 더 발전할 힘이 많이 모이는 활기찬 선순환이 만들어진다면. 그러면 그 분야를 밀어올리는 힘은 나날이 커질 수 있다. 더 많은 사람들이 더 나은 성과를 위한 문제제기와 답안제안에 참여할 수 있고, 전문가의 성과는 더 널리 이해되고 더 점검받을 수 있게된다. |
| 42 | +그러므로 쉬운 전문용어가 어떨까. 전문개념의 핵심을 쉽게 전달해주는 전문용어. 학술은 학술의 언어를—우리로서는 소리로만 읽을 원어나 한문을—사용해야만 정확하고 정밀하고 경제적일까? 아무리 정교한 전문지식이라도 쉬운 일상어로 짧고 정밀하게 전달될 수 있다. 시에서 평범한 언어로 밀도 있게 전달되는 정밀한 느낌을 겪으며 짐작되는 바이다. |
| 43 | +쉬운 전문용어가 활발히 만들어지고 테스트되는 생태계. 이것이 울타리없는 세계경쟁에서 우리를 깊고 높게 키워줄 비옥한 토양이다. 시끌벅적 쉬운말로 하는 학술의 재미는 말할것도 없다. |
| 44 | +# 원칙 |
| 45 | +쉬운 전문용어를 만들때 원칙은 다음과 같다. |
| 46 | + * 정확히 이해하기: 전문용어의 의미를 정확히 이해하도록 한다. 이해못했다면 쉬운말을 찾을 수 없다. |
| 47 | + * 쉬운말을 찾기: 그 의미가 정확히 전달되는 쉬운말을 찾는다. |
| 48 | + * 어깨힘 빼기: 이때, 어깨에 힘을 뺀다. 지레 겁먹게하는 용어(불필요한 한문투)를 피하고, 가능하면 쉬운말을 찾는다. |
| 49 | + * 하나만 필요는 없다: 전문용어 하나에 쉬운 한글용어 하나가 일대일 대응일 필요가 없이, 상황에 따라서 다양하게 풀어쓸 수 있다. 중요한 것은 의미의 명확한 전개. |
| 50 | + * 때로는 소리나는 대로: 도저히 쉬운말을 찾을 수 없을 땐, 소리나는대로 쓴다. |
| 51 | + * 때로는 만들기: 쉬운 느낌을 가진 새 말을 만들 수도 있다. 우리가 모국어의 심연을 공유하므로 가능하다. |
| 52 | + * 괄호안에 항상-I: 원문 전문용어는 괄호안에 항상 따라붙인다. |
| 53 | + * 깨어있기: 기존의 관성에 눈멀지 않는다. 이미 널리퍼진 용어지만 쉽지않다면, 보다 쉬운 전문용어를 찾고 실험한다. |
| 54 | + * 괄호안에 항상-II: 이때, 기존용어는 원문 전문용어와 함께 괄호안에 따라붙인다. |
| 55 | + * 순우리말 No, 쉬운말 Yes: 쉬운말은 순수 우리말을 뜻하지 않는다. 외래어라도 널리 쉽게 받아들여진다면 사용한다. |
| 56 | +# 쓰임 |
| 57 | +K-언어권에서 말하고 글 쓸 때 사용한다. |
| 58 | + * 설명/강의/저술/번역/블로그/SNS 등에서 한국어로 말하고 글 쓸 때 사용한다. |
| 59 | + * 쉽게쉽게 도란도란, 통쾌하게 시끌벅적, 차근차근 왁자글, 신나게 재미있게. |
| 60 | +""" |
| 61 | + |
| 62 | +def build_prompt(jargon_name: str, translations: List[str]) -> str: |
| 63 | + lines = [ |
| 64 | + "다음 용어의 쉬운 전문용어 번역 후보들을 취지에 맞게 좋은 것부터 순서를 정해봐. 순서는 첫 줄에 쉼표로 구분해서 0부터 시작해서 출력해. 용어들을 누락하면 안되고, 모든 단어들을 정렬해야 해.", |
| 65 | + "- 예시 출력: 2,0,1", |
| 66 | + f"- 전문용어: {jargon_name}", |
| 67 | + "- 쉬운 전문용어 번역 목록:", |
| 68 | + ] |
| 69 | + for idx, t in enumerate(translations): |
| 70 | + lines.append(f" {idx}. {t}") |
| 71 | + return "\n".join(lines) |
| 72 | + |
| 73 | + |
| 74 | +def parse_order(text: str, num_items: int) -> List[int]: |
| 75 | + # Extract integers and clamp to a permutation-like ordering |
| 76 | + # Fallback: identity order if parsing fails |
| 77 | + try: |
| 78 | + text = text.split("\n")[0] |
| 79 | + numbers = [int(x.strip()) for x in re.split(",| |:", text) if x.strip().isdigit()] |
| 80 | + seen = set() |
| 81 | + order = [] |
| 82 | + for n in numbers: |
| 83 | + if 0 <= n < num_items and n not in seen: |
| 84 | + order.append(n) |
| 85 | + seen.add(n) |
| 86 | + # Fill in missing indices |
| 87 | + for i in range(num_items): |
| 88 | + if i not in seen: |
| 89 | + print(f"Missing {i}") |
| 90 | + order.append(i) |
| 91 | + return order[:num_items] |
| 92 | + except Exception: |
| 93 | + print("Parsing failed") |
| 94 | + return list(range(num_items)) |
| 95 | + |
| 96 | + |
| 97 | +def main(): |
| 98 | + parser = argparse.ArgumentParser(description="Rank translations per jargon with OpenRouter") |
| 99 | + parser.add_argument("--jargon_csv", default=os.path.join("jargon.csv")) |
| 100 | + parser.add_argument("--translation_csv", default=os.path.join("translation.csv")) |
| 101 | + parser.add_argument("--output_csv", default=os.path.join("llm_ranks.csv")) |
| 102 | + parser.add_argument("--rate_limit_sec", type=float, default=0.5, help="sleep between LLM calls") |
| 103 | + args = parser.parse_args() |
| 104 | + |
| 105 | + load_dotenv() |
| 106 | + api_key = os.getenv("AI_API_KEY") |
| 107 | + if not api_key: |
| 108 | + print("Set AI_API_KEY in env.", file=sys.stderr) |
| 109 | + sys.exit(1) |
| 110 | + |
| 111 | + chat = ChatOpenAI(api_key=api_key, base_url="https://openrouter.ai/api/v1", model="openai/gpt-5") |
| 112 | + |
| 113 | + # Load CSVs |
| 114 | + jargons = pd.read_csv(args.jargon_csv) |
| 115 | + translations = pd.read_csv(args.translation_csv) |
| 116 | + |
| 117 | + # minimal columns validation |
| 118 | + for col in ["id", "name"]: |
| 119 | + if col not in jargons.columns: |
| 120 | + raise ValueError(f"jargon.csv missing column: {col}") |
| 121 | + for col in ["id", "name", "jargon_id"]: |
| 122 | + if col not in translations.columns: |
| 123 | + raise ValueError(f"translation.csv missing column: {col}") |
| 124 | + |
| 125 | + # Group translations by jargon_id |
| 126 | + grouped = translations.groupby("jargon_id") |
| 127 | + |
| 128 | + # Load existing progress if present |
| 129 | + existing_map: Dict[str, int] = {} |
| 130 | + if os.path.exists(args.output_csv): |
| 131 | + try: |
| 132 | + existing_df = pd.read_csv(args.output_csv) |
| 133 | + if "translation_id" in existing_df.columns and "llm_rank" in existing_df.columns: |
| 134 | + for row in existing_df.itertuples(index=False): |
| 135 | + try: |
| 136 | + existing_map[str(row.translation_id)] = int(row.llm_rank) |
| 137 | + except Exception: |
| 138 | + continue |
| 139 | + print(f"Loaded existing ranks: {len(existing_map)} from {args.output_csv}") |
| 140 | + except Exception as e: |
| 141 | + print(f"Warning: failed to read existing output {args.output_csv}: {e}", file=sys.stderr) |
| 142 | + |
| 143 | + def write_progress() -> None: |
| 144 | + tmp_path = args.output_csv + ".tmp" |
| 145 | + out_df = pd.DataFrame({"translation_id": list(existing_map.keys()), "llm_rank": list(existing_map.values())}) |
| 146 | + out_df = out_df.sort_values(by=["translation_id"]).reset_index(drop=True) |
| 147 | + out_df.to_csv(tmp_path, index=False) |
| 148 | + os.replace(tmp_path, args.output_csv) |
| 149 | + |
| 150 | + # Build a map for jargon_id -> jargon_name for prompts |
| 151 | + jargon_name_by_id = {row.id: row.name for row in jargons.itertuples(index=False)} |
| 152 | + |
| 153 | + for jargon_id, group in tqdm(grouped): |
| 154 | + names = group["name"].fillna("").astype(str).tolist() |
| 155 | + tids = group["id"].astype(str).tolist() |
| 156 | + |
| 157 | + # Skip GPT if every translation in this group already ranked |
| 158 | + if all(tid in existing_map for tid in tids): |
| 159 | + continue |
| 160 | + |
| 161 | + if len(names) <= 1: |
| 162 | + changed = False |
| 163 | + for idx, tid in enumerate(tids): |
| 164 | + if existing_map.get(tid) != idx: |
| 165 | + existing_map[tid] = idx |
| 166 | + changed = True |
| 167 | + if changed: |
| 168 | + write_progress() |
| 169 | + continue |
| 170 | + |
| 171 | + jargon_name = jargon_name_by_id.get(jargon_id, "") |
| 172 | + prompt = build_prompt(jargon_name, names) |
| 173 | + |
| 174 | + tqdm.write(f"Jargon: {jargon_name}, Translations: {names}") |
| 175 | + try: |
| 176 | + response = chat.invoke([SystemMessage(content=SYSTEM_PROMPT), HumanMessage(content=prompt)]) |
| 177 | + text = getattr(response, "content", "") or str(response) |
| 178 | + except Exception as e: |
| 179 | + # Fallback: identity order on errors |
| 180 | + tqdm.write(f"Error: {e}") |
| 181 | + text = "" |
| 182 | + finally: |
| 183 | + if args.rate_limit_sec > 0: |
| 184 | + time.sleep(args.rate_limit_sec) |
| 185 | + |
| 186 | + tqdm.write("===\n" + text) |
| 187 | + order = parse_order(text, len(names)) |
| 188 | + tqdm.write(",".join([str(x) for x in order])) |
| 189 | + # Assign llm_rank based on order index |
| 190 | + inverse_rank = [0] * len(order) |
| 191 | + for rank, original_idx in enumerate(order): |
| 192 | + inverse_rank[original_idx] = rank |
| 193 | + |
| 194 | + for original_idx, tid in enumerate(tids): |
| 195 | + existing_map[tid] = inverse_rank[original_idx] |
| 196 | + write_progress() |
| 197 | + |
| 198 | + # Final summary |
| 199 | + print(f"Wrote {len(existing_map)} rows to {args.output_csv}") |
| 200 | + |
| 201 | + |
| 202 | +if __name__ == "__main__": |
| 203 | + main() |
0 commit comments