Skip to content

Commit 15709d1

Browse files
committed
✨ Sorting via GPT-5
1 parent 48aa7cf commit 15709d1

File tree

10 files changed

+340
-4
lines changed

10 files changed

+340
-4
lines changed

components/jargon/jargon-translations-section.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export default function JargonTranslationsSection({
1919
name: string;
2020
translations: TranslationListItem[];
2121
}) {
22-
const [sort, setSort] = useState<TranslationSortOption>("recent");
22+
const [sort, setSort] = useState<TranslationSortOption>("llm");
2323

2424
return (
2525
<div className="flex flex-col gap-2">

components/jargon/translation-list.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@
33
import { useMemo } from "react";
44
import TranslationActions from "@/components/jargon/translation-actions";
55

6-
export type TranslationSortOption = "recent" | "abc" | "zyx";
6+
export type TranslationSortOption = "recent" | "abc" | "zyx" | "llm";
77

88
export interface TranslationListItem {
99
id: string;
1010
name: string;
1111
author_id: string;
1212
updated_at?: string;
13+
llm_rank?: number | null;
1314
}
1415

1516
export default function TranslationList({
@@ -31,6 +32,14 @@ export default function TranslationList({
3132
copy.sort((a, b) => a.name.localeCompare(b.name, "ko"));
3233
} else if (sort === "zyx") {
3334
copy.sort((a, b) => b.name.localeCompare(a.name, "ko"));
35+
} else if (sort === "llm") {
36+
copy.sort((a, b) => {
37+
const aRank = a.llm_rank ?? Number.POSITIVE_INFINITY;
38+
const bRank = b.llm_rank ?? Number.POSITIVE_INFINITY;
39+
if (aRank !== bRank) return aRank - bRank; // lower rank first; nulls last
40+
// Stable fallback by name for ties
41+
return a.name.localeCompare(b.name, "ko");
42+
});
3443
}
3544
return copy;
3645
}, [translations, sort]);

components/jargon/translation-sort-button.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,12 @@ export default function TranslationSortButton({
3838
value={value}
3939
onValueChange={(val) => onChange(val as TranslationSortOption)}
4040
>
41+
<DropdownMenuRadioItem value="llm">AI 추천순</DropdownMenuRadioItem>
4142
<DropdownMenuRadioItem value="recent">
4243
최근 활동순
4344
</DropdownMenuRadioItem>
4445
<DropdownMenuRadioItem value="abc">가나다순</DropdownMenuRadioItem>
45-
<DropdownMenuRadioItem value="zyx">하파카순</DropdownMenuRadioItem>
46+
{/* <DropdownMenuRadioItem value="zyx">하파카순</DropdownMenuRadioItem> */}
4647
</DropdownMenuRadioGroup>
4748
</DropdownMenuContent>
4849
</DropdownMenu>

lib/supabase/repository.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export const QUERIES = {
3535
return supabase
3636
.from("jargon")
3737
.select(
38-
"id, name, slug, created_at, author_id, translations:translation(id, name, author_id, updated_at), categories:jargon_category(category:category(id, name, acronym))",
38+
"id, name, slug, created_at, author_id, translations:translation(id, name, author_id, updated_at, llm_rank), categories:jargon_category(category:category(id, name, acronym))",
3939
)
4040
.eq("slug", slug)
4141
.limit(1)

lib/supabase/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,7 @@ export type Database = {
292292
created_at: string
293293
id: string
294294
jargon_id: string
295+
llm_rank: number | null
295296
name: string
296297
updated_at: string
297298
}
@@ -301,6 +302,7 @@ export type Database = {
301302
created_at?: string
302303
id?: string
303304
jargon_id: string
305+
llm_rank?: number | null
304306
name: string
305307
updated_at?: string
306308
}
@@ -310,6 +312,7 @@ export type Database = {
310312
created_at?: string
311313
id?: string
312314
jargon_id?: string
315+
llm_rank?: number | null
313316
name?: string
314317
updated_at?: string
315318
}

scripts/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
*.csv
2+
.env

scripts/dump.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from sqlalchemy import create_engine
2+
from sqlalchemy.pool import NullPool
3+
from dotenv import load_dotenv
4+
import pandas as pd
5+
import os
6+
7+
load_dotenv()
8+
USER = os.getenv("user")
9+
PASSWORD = os.getenv("password")
10+
HOST = os.getenv("host")
11+
PORT = os.getenv("port")
12+
DBNAME = os.getenv("dbname")
13+
14+
DATABASE_URL = f"postgresql+psycopg2://{USER}:{PASSWORD}@{HOST}:{PORT}/{DBNAME}?sslmode=require"
15+
16+
engine = create_engine(DATABASE_URL, poolclass=NullPool)
17+
18+
tables = ["category", "comment", "html", "jargon", "jargon_category", "translation"]
19+
try:
20+
with engine.connect() as connection:
21+
for table in tables:
22+
print(f"Table: {table}")
23+
df = pd.read_sql(f"SELECT * FROM public.{table}", engine)
24+
n_rows, n_cols = df.shape
25+
print(f" Rows: {n_rows}, Columns: {n_cols}")
26+
print(" Columns: ", list(df.columns))
27+
28+
df.to_csv(f"{table}.csv", index=False)
29+
except Exception as e:
30+
print(f"Failed to connect: {e}")

scripts/rank_translations_llm.py

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
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

Comments
 (0)