Cost, Effort Information
To build an intelligence that understands what is observed, what remains uncertain, and what evidence is needed for reliable action.
To build a scientific foundationfor sensor-based understanding of real-world environments, grounded in observability, uncertainty, and spatial reasoning.
To develop robot perception systemsthat can localize, map, recognize, and interact with the physical world while knowing the limits of their own perception.
To advance spatial representationsthat support certifiable world claims, cross-modal reasoning, long-term memory, and operation under ambiguity and change.
To define the principlesthat let robots identify what is observable, locate the boundary of reliable perception, and acquire the evidence needed for action.
지금 진행 중인 프로젝트를 따라가면 두 가지 연구 축이 드러납니다.
하나는 로봇이 센서로 본 장면을 얼마나 믿을 수 있는지 묻고
다른 하나는 부족한 정보를 언제 어떻게 보충해야 하는지 묻습니다.
센서 관측을 공간 정보로 바꾸는 연구입니다. 이 정보는 위치, 지도, 장소 인식과 행동에 쓰입니다.
여러 센서가 서로 다른 단서를 줄 때,
로봇은 무엇을 믿을 수 있고 무엇은 아직 모른다고 해야 할까?
위치, 지도, 장소, 행동에 관한 판단은
언제 충분히 믿을 만하다고 말할 수 있을까?
센서와 시점, 환경과 시간이 달라져도
같은 장면과 장소를 어떻게 알아볼 수 있을까?
로봇이 지금 처한 조건에서 무엇을 알 수 있는지, 다음에는 무엇을 확인해야 하는지를 다룹니다.
위치와 지도를 추정하는 데서 그치지 않고
실제 세계에 관한 판단까지 믿을 수 있게 만들려면 무엇이 필요할까?
로봇이 지금 알 수 있는 것과 알 수 없는 것의 경계는 어디이며
그 경계를 넓히려면 무엇을 더 관측해야 할까?
오래 쓰거나 여러 로봇이 함께 쓰는 공간 지식은
언제 다시 확인하고 어떻게 갱신하고 공유해야 할까?
현재 프로젝트는 Navigation, Mapping, Spatial AI로 나뉩니다.
각 분야에서 풀고 있는 문제는 두 연구 축의 질문으로 이어집니다.
Spatial AI for Robots센서와 로봇, 환경이 달라져도
위치를 찾고 안전하게 움직이려면 무엇을 믿어야 할까?
Situated Knowability현재 관측만으로 위치와 경로를 정하기 어렵다면
무엇을 더 확인해야 할까?
Spatial AI for Robots여러 센서로 얻은 관측을
하나의 정확한 공간 정보로 어떻게 묶을 수 있을까?
Situated Knowability만든 지도를 어디까지 믿을 수 있고,
언제 다시 확인하거나 고쳐야 할까?
Spatial AI for Robots움직이는 물체와 모호한 지시가 섞인 장면에서
무엇이 확실하고 무엇이 불확실할까?
Situated Knowability로봇이 알 수 있는 범위를 넓히려면
어디로 움직이고 무엇을 다시 봐야 할까?
Go2 Pro 내부 Ethernet을 Jetson Orin Nano Super에 직접 연결하고, Jetson에서 비공식 go2_ros2_sdk를 실행한다. Go2에 SSH로 들어가는 구성은 아니다. Jetson의 유선 NIC를 로봇과 같은 대역으로 맞춘 뒤 WebRTC endpoint를 ROS 2 topic으로 변환한다.
Go2 Pro 내부 Ethernet ⇄ Jetson NIC · 192.168.123.99/24
찾아낸 ROBOT_IP → WebRTC → go2_ros2_sdk → ROS 2
두 가지 실물 기준 — 우리 Go2의 상판 내부 전원 포트는 12V로 실측했다. Jetson Orin Nano Super의 DC 입력 범위는 9–20V이므로 강압 회로는 쓰지 않는다. 로봇의 Ethernet IP는 고정값으로 넘겨짚지 않고 연결한 장비에서 직접 찾는다.
상판 내부 Ethernet과 Jetson을 LAN 케이블로 연결한다. 이 링크에서는 DHCP나 기본 route를 기대하지 않고 Jetson의 유선 NIC에 192.168.123.99/24를 직접 지정한다. Wi-Fi의 인터넷 route가 바뀌지 않도록 gateway와 DNS는 넣지 않는다.
ip -br link
sudo nmcli connection add type ethernet \
ifname enP1p1s0 con-name go2-internal \
ipv4.method manual ipv4.addresses 192.168.123.99/24 \
ipv4.never-default yes ipv6.method disabled
sudo nmcli connection up go2-internal
ip -4 address show dev enP1p1s0
enP1p1s0는 예시다. 첫 명령에서 확인한 Jetson의 실제 유선 인터페이스 이름으로 바꾼다.
통과 기준 — 해당 NIC에 192.168.123.99/24가 표시되고 Wi-Fi가 계속 기본 route로 남는다.
Go2 Pro에 SSH로 로그인하지 않는다. 공개된 SSH 비밀번호가 없고, 이 셋업에서도 로봇 shell에는 접속하지 않았다. Jetson에서 Ethernet 대역을 스캔해 응답하는 장치와 WebRTC signaling port를 찾는다.
sudo apt update
sudo apt install -y arp-scan netcat-openbsd
sudo arp-scan --interface=enP1p1s0 192.168.123.0/24
ip neighbour show dev enP1p1s0
nc -vz ROBOT_IP 9991
nc -vz ROBOT_IP 8081
ip route get ROBOT_IP
검색된 주소를 곧바로 고정값으로 믿지 않고, Go2를 껐다 켰을 때 사라졌다 다시 나타나는지 확인한다. 이후 명령의 ROBOT_IP에는 이 주소를 넣는다.
통과 기준 — Jetson에서 유선 NIC를 통해 확인한 로봇 IP에 접속할 수 있고 WebRTC signaling port 가운데 하나가 응답한다.
JetPack 6의 Ubuntu 22.04에 ROS 2 Humble을 설치한다. 그 위에 Pro와 WebRTC 연결을 지원하는 비공식 go2_ros2_sdk를 빌드한다.
mkdir -p ~/go2_ws
cd ~/go2_ws
git clone --recurse-submodules \
https://github.com/abizovnuralem/go2_ros2_sdk.git src
source /opt/ros/humble/setup.bash
sudo apt install -y python3-pip clang portaudio19-dev \
ros-humble-image-tools ros-humble-vision-msgs
python3 -m pip install -r src/requirements.txt
rosdep install --from-paths src --ignore-src -r -y
colcon build --symlink-install
통과 기준 — colcon build가 끝나고 ~/go2_ws/install/이 생성된다.
휴대전화 앱의 로봇 연결을 닫고 Jetson에서 SDK를 실행한다. 처음에는 RViz, Nav2, SLAM과 조이스틱을 모두 끄고 ROS 통신만 확인한다.
source /opt/ros/humble/setup.bash
source ~/go2_ws/install/setup.bash
export ROBOT_IP="찾아낸_로봇_IP"
export CONN_TYPE="webrtc"
ros2 launch go2_robot_sdk robot.launch.py \
rviz2:=false nav2:=false slam:=false \
foxglove:=false joystick:=false teleop:=false
Go2 펌웨어 1.1.15 이상에서 암호화 handshake 오류가 날 때만 저장소 안내에 따라 기기별 키를 ROBOT_AES_KEY 환경변수로 추가한다. 비밀번호나 키는 문서와 공개 저장소에 기록하지 않는다.
ros2 node list
ros2 topic list --types
ros2 topic info --verbose 실제_상태_TOPIC
ros2 topic echo --once 실제_상태_TOPIC
확인한 범위 — Jetson에서 ROS 2 node와 topic이 만들어지고, 로봇에서 온 상태 message 하나가 실제로 수신되는 것까지다. SSH 접속, 개별 센서 검증과 주행 제어는 이 확인 범위에 포함하지 않는다.
Go2의 배터리를 분리하고 상판을 연다. 내부 12V 포트에서 12V와 GND 두 선을 빼고, 반대쪽은 Jetson의 5.5×2.5mm DC plug에 납땜한다. 납땜부를 절연한 뒤 Jetson을 스탠드오프로 고정하고 LAN·전원선이 당겨지지 않게 묶는다.
배선 확인 — Jetson을 연결하기 전에 DC plug 끝에서 12V와 극성을 다시 측정한다.
Go2 ROS2 SDK · Unitree ROS2 네트워크 설정 · Unitree WebRTC Connect · Jetson Orin Nano 하드웨어 안내 · Carrier Board Specification
Slack에서 질문하면 nanobot이 로컬 LLM 머신을 거쳐 Obsidian vault의 논문 메모와 실험 기록을 읽어 답하도록 설정한다. GPU 모델을 특정할 필요는 없다. 사용할 머신의 VRAM에 맞춰 모델 크기와 양자화 수준을 고르면 된다. 설치할 때는 로컬 모델부터 띄우고 nanobot, vault, Slack 순서로 붙인다.
Slack → nanobot gateway → Ollama · quantized local LLM → Obsidian vault
NVIDIA GPU가 있는 Linux 머신이라면 아래 명령으로 GPU 이름과 VRAM을 확인한다. Apple Silicon은 별도 VRAM 대신 unified memory를 함께 쓰므로 운영체제와 다른 프로그램이 사용할 여유를 남겨야 한다. 지원 하드웨어는 Ollama hardware 안내에서 확인한다.
nvidia-smi --query-gpu=name,memory.total --format=csv
curl -fsSL https://ollama.com/install.sh | sh
ollama -v
curl http://127.0.0.1:11434/api/tags
통과 기준 — 가용 메모리 크기를 기록했고 Ollama API가 모델 목록을 JSON으로 돌려준다.
파라미터 수가 같아도 저장 정밀도에 따라 필요한 메모리가 달라진다. 같은 VRAM에 더 큰 모델을 올리려면 보통 Q4_K_M부터 본다. Q8_0은 메모리를 더 쓰는 대신 양자화 손실이 작고, FP16은 로컬 에이전트를 처음 띄울 때는 대개 비효율적이다. 모델 이름은 설치하는 날 다시 확인한다. 아래 태그는 2026년 8월 Ollama에 올라온 배포본을 기준으로 한 예다.
현재 최신 Qwen 세대인 Qwen3.8-27B의 공식 Ollama Q4 파일은 18 GB다. 따라서 16 GB GPU에는 가중치부터 전부 들어가지 않는다. 이 모델을 그대로 쓰면 일부가 CPU로 내려가고, 전부 GPU에 올리려면 24 GB급이 필요하다. 16 GB에서 속도와 긴 context가 중요하면 9B Q4로 내려가는 편이 낫다.
| 사용 가능 메모리 | 먼저 시험할 태그 | 모델 파일 | 선택 기준 |
|---|---|---|---|
| 6–8 GB | qwen3.5:4b | 3.4 GB | 4K context로 검색과 대화 연결부터 확인한다. |
| 10–12 GB | qwen3.5:9b | 6.6 GB | 연구 메모 검색과 tool calling을 시작하기 무난하다. |
| 16 GB · 속도 우선 | qwen3.5:9b | 6.6 GB | 모델과 KV cache를 GPU에 남기고 16K 이상 context를 시험할 수 있다. |
| 16 GB · 최신 세대 시험 | qwen3.8:27b-q4_K_M | 18 GB | CPU offload가 생긴다. 더 낮은 비트의 community quant는 직접 품질을 다시 확인해야 한다. |
| 24 GB | qwen3.8:27b-q4_K_M | 18 GB | 짧은 context부터 시작해 GPU에 전부 올라가는지 확인한다. |
| 40 GB 이상 | qwen3.8:27b-q8_0 | 30 GB | Q8과 긴 context를 함께 시험할 여유가 있다. |
Ollama 태그 목록의 파일 크기는 실제 VRAM 사용량과 같지 않다. 가중치 외에도 context의 KV cache와 실행 메모리가 필요하다. 아래는 최신 세대 모델을 시험하는 예다. 16 GB에서는 CPU offload를 감수하고, 속도가 느리면 표의 9B 태그로 바꾼다.
ollama pull qwen3.8:27b-q4_K_M
ollama show qwen3.8:27b-q4_K_M
ollama run qwen3.8:27b-q4_K_M
짧은 질문에 답이 오면 /bye로 대화를 끝내고 적재 상태를 확인한다.
ollama ps
curl http://127.0.0.1:11434/api/ps
Ollama context 안내처럼 context를 늘리면 메모리 사용도 함께 늘어난다. 처음에는 4K로 시작해 8K, 16K 순으로 올린다. 16 GB에서 27B Q4를 쓰면 ollama ps에 CPU/GPU가 나뉘어 나오는 것이 정상이다. 전부 GPU에 올리고 싶거나 응답이 지나치게 느리면 9B Q4로 내려간다.
공개 점수만 놓고 보면 Qwen3.8-27B는 Gemini 3.5 Flash와 비슷한 구간에 있다. Terminal-Bench 2.1은 73.0 대 76.2, SWE-bench Pro는 61.7 대 55.1, OSWorld-Verified는 84.3 대 78.4, CharXiv는 83.7 대 84.2다. 다만 Qwen 결과와 Gemini 결과는 실행 조건이 같지 않고, 16 GB용 저비트 양자화판을 직접 비교한 값도 아니다. 이 점수만으로 두 모델이 동급이라고 보거나 로컬에서 논문 메모를 찾고 실험 기록을 비교하는 데 충분하다고 판단할 수는 없다. 실제로 사용할 양자화판으로 뒤의 연구 맥락 확인 단계를 거쳐야 한다.
통과 기준 — 선택한 태그와 양자화 수준이 ollama show에 표시되고, OOM 없이 답한다. ollama ps에서 GPU 적재 비율과 첫 응답 시간을 함께 기록한다.
nanobot은 별도 가상환경에 설치한다. 설정 마법사가 끝나면 기본 설정은 ~/.nanobot/config.json, 상태 파일은 ~/.nanobot/workspace/ 아래에 생긴다.
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -U nanobot-ai
nanobot onboard --wizard
통과 기준 — nanobot status가 실제로 읽고 있는 config와 workspace 경로를 오류 없이 보여 준다.
nanobot provider 안내를 기준으로 config.json의 기존 값은 보존하고 아래 세 구역을 합친다. 모델 이름은 앞에서 내려받은 이름과 글자까지 같아야 한다.
{
"providers": {
"ollama": {
"apiBase": "http://127.0.0.1:11434"
}
},
"modelPresets": {
"local-quantized": {
"provider": "ollama",
"model": "qwen3.8:27b-q4_K_M",
"maxTokens": 1536,
"contextWindowTokens": 4096,
"temperature": 0.2
}
},
"agents": {
"defaults": {
"modelPreset": "local-quantized"
}
}
}
nanobot status
nanobot agent -m "한 문장으로 현재 연결 상태를 답해 줘."
통과 기준 — nanobot의 답이 터미널에 나오고 같은 시각에 ollama ps에서 모델이 실행 중이다.
처음에는 개인 vault 전체를 연결하지 말고 논문 메모와 실험 기록만 복사한 전용 vault로 시작한다. 그 vault의 최상위에 AGENTS.md를 만들고 읽기와 쓰기의 경계를 적는다.
# Research vault rules
- 답변마다 근거가 된 노트의 상대 경로를 적는다.
- 명시적으로 요청하지 않으면 기존 노트를 수정하지 않는다.
- 새 메모는 00-Inbox/ 아래에 초안으로 작성한다.
- 기록된 사실, 문헌의 주장, 추측을 구분해서 쓴다.
절대 경로를 넣어 vault만 대상으로 한 질문을 보낸다.
nanobot agent \
--workspace "/absolute/path/to/robotics-vault" \
-m "최상위 폴더를 나열하고, 읽은 파일 경로를 함께 적어 줘."
통과 기준 — vault 안의 경로만 답에 나오고 기존 노트는 바뀌지 않는다.
Slack API에서 From scratch로 앱을 만든다. Socket Mode를 켜고 connections:write scope의 App-Level Token(xapp-…)을 만든다. OAuth & Permissions에는 chat:write, app_mentions:read, channels:history, groups:history, im:history를 추가한다.
Event Subscriptions의 bot events에는 message.im, message.channels, app_mention을 넣는다. App Home의 Messages Tab을 켠 뒤 앱을 workspace에 설치하고 Bot Token(xoxb-…)을 복사한다. 파일도 주고받으려면 files:read와 files:write를 추가한 뒤 앱을 다시 설치해야 한다.
테스트할 비공개 채널 하나를 만들고 Slack 앱을 초대한다. Slack의 사용자 ID(U…)와 채널 ID(C…)를 복사한 뒤, 기존 config.json의 channels 안에 아래 내용을 합친다. 실제 token을 JSON에 직접 넣지 않고 환경변수를 참조한다.
{
"channels": {
"slack": {
"enabled": true,
"botToken": "${SLACK_BOT_TOKEN}",
"appToken": "${SLACK_APP_TOKEN}",
"allowFrom": ["U_YOUR_USER_ID"],
"groupPolicy": "allowlist",
"groupAllowFrom": ["C_YOUR_TEST_CHANNEL_ID"],
"groupRequireMention": true
}
}
}
nanobot plugins enable slack
export SLACK_BOT_TOKEN="xoxb-..."
export SLACK_APP_TOKEN="xapp-..."
nanobot channels status
중요 — token을 export한 터미널과 gateway를 실행하는 터미널이 같아야 한다. 채널을 넓히기 전에는 allowFrom과 groupAllowFrom을 그대로 둔다.
Slack 연결과 background 작업은 gateway가 맡는다. 앞에서 시험한 것과 같은 vault 경로로 실행하고 이 프로세스를 계속 켜 둔다.
nanobot gateway \
--workspace "/absolute/path/to/robotics-vault"
Slack에서 먼저 bot에게 DM을 보내고 테스트 채널에서는 @bot이름을 붙여 질문한다. 이어서 “특정 노트 찾기”, “두 실험 기록 비교하기”, “00-Inbox/에 새 초안 만들기”, “허용하지 않은 채널에서 호출하기”를 차례로 시험한다.
완료 기준 — DM과 허용 채널에서만 답하고 답에 참고한 노트 경로가 있으며 새 파일은 00-Inbox/에만 만들어진다. Slack 메시지가 오지 않으면 nanobot gateway --verbose에서 token, event scope, 사용자·채널 ID를 순서대로 확인한다.
단순한 로보틱스 상식 질문이 아니라, 여러 기록의 차이를 찾아야 답할 수 있는 질문으로 시험한다. 예를 들어 vault에 다음 두 실험 기록이 있다고 하자.
Experiments/2026-08-28-corridor.md
- 회전 구간에서 localization error 증가
- D455 exposure: 20 ms, IMU time offset: 2 ms 미만
Experiments/2026-08-21-corridor.md
- 같은 경로의 성공 run
- D455 exposure: 8 ms, LiDAR·IMU 설정은 8월 28일과 동일
모델은 답을 새로 꾸미지 않아야 한다. 날짜가 다른 두 기록에서 설정 차이를 찾고, 이어지는 질문에서는 앞선 비교 대상을 다시 적지 않아도 그 맥락을 유지해야 한다.
완료 기준 — 답변에 실제 노트 경로가 붙고 기록에 있는 사실과 에이전트의 추론이 구분되며 후속 질문에서 앞선 실험과 변경 변수를 그대로 이어 간다.
arXiv RSS는 Slack의 원문 채널에 그대로 보관하고, LLM이 고른 논문만 별도 채널에 올린다. API token이 있으면 Google Apps Script에서 구독 중인 API를 호출하는 구성이 가장 간단하다. 현재 쓰는 것도 이 방식이다. 별도 LLM API가 없다면 로컬 Ollama로도 충분하다. 다만 Apps Script는 내 컴퓨터의 127.0.0.1에 접근할 수 없으므로, 그 경우에는 RSS 수집기까지 로컬 머신에서 실행한다.
원문: arXiv RSS → Slack RSS app → #arxiv-inbox
API 사용: arXiv RSS → Google Apps Script → subscribed LLM API → Slack webhook → #arxiv-selected
로컬 사용: arXiv RSS → local scheduler → Ollama → Slack webhook → #arxiv-selected
#arxiv-inbox에는 RSS 원문을 빠짐없이 남기고, #arxiv-selected에는 LLM이 고른 논문만 보낸다. 나중에 선별 기준을 바꿀 때 두 채널을 비교하면 놓친 논문이 바로 드러난다.
Slack RSS 앱을 워크스페이스에 설치한다. #arxiv-inbox에서 아래 명령을 보내 로보틱스, 컴퓨터 비전, 인공지능 분야를 하나의 피드로 구독한다.
/feed subscribe https://rss.arxiv.org/rss/cs.RO+cs.CV+cs.AI
/feed list
분야 코드는 arXiv RSS 안내에 따라 바꾸면 된다. 처음에는 cs.RO만 받아도 된다. 피드가 하루 단위로 갱신되므로 구독하자마자 메시지가 오지 않을 수 있다.
여기까지 확인 — /feed list에 주소와 채널이 나오고, 다음 갱신 뒤 inbox에 논문 제목과 링크가 들어온다.
Slack Incoming Webhooks 안내대로 앱을 하나 만들고 Incoming Webhooks를 켠다. Add New Webhook to Workspace에서 #arxiv-selected를 고르면 https://hooks.slack.com/services/… 형식의 URL이 나온다. 이 URL은 채널에 메시지를 쓸 수 있는 비밀값이므로 코드나 공개 저장소에 넣지 않는다.
여기까지 확인 — webhook 설정 화면의 예제 curl을 실행했을 때 #arxiv-selected에 테스트 문장이 온다.
Google Apps Script에서 새 프로젝트를 만든다. Project Settings → Script Properties에 아래 값을 추가한다. API_STYLE은 호출할 API에 맞춰 openai 또는 anthropic으로 적는다. 다른 회사의 API라도 두 형식 중 하나와 호환되면 그대로 쓴다. API가 없다면 이 단계와 다음 Apps Script 코드를 건너뛰고 뒤의 로컬 실행 단계로 간다.
API_STYLE openai
API_ENDPOINT https://your-provider.example/v1/chat/completions
API_TOKEN 발급받은 API token
API_MODEL 사용 중인 model 이름
SLACK_WEBHOOK_URL https://hooks.slack.com/services/...
RSS_URL https://rss.arxiv.org/rss/cs.RO+cs.CV+cs.AI
RESEARCH_PROFILE 아래 선별 기준 전체
포함:
- LiDAR·camera localization과 mapping
- 동적 환경의 3D perception과 uncertainty
- 실제 로봇 또는 공개 dataset으로 검증한 연구
제외:
- 로보틱스와 관계없는 순수 생성 모델
- 의료·위성 영상처럼 현재 프로젝트와 무관한 응용
- 초록에 방법이나 실험 기여가 드러나지 않는 논문
출력:
- 최대 6편
- 제목, arXiv URL, 지금 읽을 이유, 관련 프로젝트, 확신도
- 초록에 없는 내용은 추측하지 않기
Code.gs를 아래 코드로 바꾼다. Apps Script는 새 RSS 항목만 API에 보낸다. Slack 게시에 성공하면 arXiv ID를 Script Properties에 기록한다. token은 코드가 아니라 앞에서 만든 속성에서 읽는다.
function collectArxiv() {
const store = PropertiesService.getScriptProperties();
const p = store.getProperties();
const xml = UrlFetchApp.fetch(p.RSS_URL).getContentText();
const channel = XmlService.parse(xml).getRootElement().getChild('channel');
const papers = channel.getChildren('item').slice(0, 40).map(item => ({
title: item.getChildText('title').trim(),
url: item.getChildText('link').trim(),
abstract: item.getChildText('description')
.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
}));
const seen = JSON.parse(p.SEEN_IDS || '[]');
const fresh = papers.filter(x => !seen.includes(arxivId_(x.url)));
if (!fresh.length) return;
const prompt = [
'다음은 오늘 arXiv RSS에 올라온 논문의 제목과 초록이다.',
p.RESEARCH_PROFILE,
'선별할 논문이 없으면 "오늘은 해당 논문 없음"이라고만 답한다.',
JSON.stringify(fresh)
].join('\n\n');
const answer = callLlm_(p, prompt);
UrlFetchApp.fetch(p.SLACK_WEBHOOK_URL, {
method: 'post',
contentType: 'application/json',
payload: JSON.stringify({ text: answer })
});
const ids = fresh.map(x => arxivId_(x.url));
store.setProperty('SEEN_IDS', JSON.stringify([...new Set(seen.concat(ids))].slice(-500)));
}
function callLlm_(p, prompt) {
const anthropic = p.API_STYLE === 'anthropic';
const headers = anthropic
? { 'x-api-key': p.API_TOKEN, 'anthropic-version': '2023-06-01' }
: { Authorization: 'Bearer ' + p.API_TOKEN };
const body = anthropic
? { model: p.API_MODEL, max_tokens: 1400, messages: [{ role: 'user', content: prompt }] }
: { model: p.API_MODEL, messages: [{ role: 'user', content: prompt }], temperature: 0.2 };
const res = UrlFetchApp.fetch(p.API_ENDPOINT, {
method: 'post',
contentType: 'application/json',
headers,
payload: JSON.stringify(body),
muteHttpExceptions: true
});
if (res.getResponseCode() >= 300) throw new Error(res.getContentText());
const data = JSON.parse(res.getContentText());
return anthropic
? data.content.map(x => x.text || '').join('')
: data.choices[0].message.content;
}
function arxivId_(url) {
const match = url.match(/\/abs\/([^?#]+)/);
return match ? match[1] : url;
}
API 형식이 다를 때 — endpoint와 token을 넣었는데 4xx 오류가 나면 callLlm_()의 header, body, 응답 한 줄만 해당 서비스 문서에 맞춘다. RSS 수집과 Slack 전송 코드는 바꿀 필요가 없다.
로보틱스 연구용 에이전트에서 띄운 Ollama를 그대로 써도 된다. Google 서버에서 내 PC의 localhost를 부를 수는 없으므로, 아래 수집기를 Ollama와 같은 머신에 둔다. 먼저 작업 폴더와 가상환경을 만들고 필요한 패키지만 설치한다.
mkdir arxiv-filter
cd arxiv-filter
python3 -m venv .venv
source .venv/bin/activate
python -m pip install feedparser requests
앞에서 만든 포함·제외 기준을 research-profile.txt에 저장하고, 아래 코드를 arxiv_local.py로 저장한다. 모델 태그는 실제로 Ollama에 받아 둔 이름을 쓴다.
import json
import os
from pathlib import Path
import feedparser
import requests
RSS_URL = os.getenv('RSS_URL', 'https://rss.arxiv.org/rss/cs.RO+cs.CV+cs.AI')
OLLAMA_URL = os.getenv('OLLAMA_URL', 'http://127.0.0.1:11434/api/chat')
MODEL = os.environ['OLLAMA_MODEL']
WEBHOOK = os.environ['SLACK_WEBHOOK_URL']
STATE = Path('seen_arxiv.json')
PROFILE = Path('research-profile.txt').read_text(encoding='utf-8')
seen = json.loads(STATE.read_text()) if STATE.exists() else []
feed = feedparser.parse(RSS_URL)
fresh = []
for entry in feed.entries[:40]:
paper_id = entry.link.split('/abs/')[-1]
if paper_id in seen:
continue
fresh.append({
'id': paper_id,
'title': entry.title,
'url': entry.link,
'abstract': entry.get('summary', '')
})
if not fresh:
raise SystemExit('새 논문 없음')
prompt = '\n\n'.join([
'다음은 오늘 arXiv RSS에 올라온 논문의 제목과 초록이다.',
PROFILE,
'선별할 논문이 없으면 "오늘은 해당 논문 없음"이라고만 답한다.',
json.dumps(fresh, ensure_ascii=False)
])
response = requests.post(OLLAMA_URL, json={
'model': MODEL,
'messages': [{'role': 'user', 'content': prompt}],
'stream': False,
'options': {'temperature': 0.2}
}, timeout=600)
response.raise_for_status()
answer = response.json()['message']['content']
posted = requests.post(WEBHOOK, json={'text': answer}, timeout=30)
posted.raise_for_status()
ids = [paper['id'] for paper in fresh]
STATE.write_text(json.dumps(list(dict.fromkeys(seen + ids))[-500:]))
먼저 터미널에서 한 번 실행한다. 16 GB에서 Qwen3.8-27B Q4를 쓰면 CPU offload 때문에 느릴 수 있다. 매일 논문 수십 편을 한 번 거르는 작업은 실시간 대화가 아니므로 기다릴 수 있다면 그대로 써도 되고, 속도가 중요하면 9B Q4로 바꾼다.
export OLLAMA_MODEL='qwen3.8:27b-q4_K_M'
export SLACK_WEBHOOK_URL='https://hooks.slack.com/services/...'
.venv/bin/python arxiv_local.py
정상 실행되면 아래 내용을 run-local.sh로 저장한다. webhook이 들어 있으므로 파일 권한은 chmod 700 run-local.sh로 제한한다.
#!/bin/sh
cd /absolute/path/arxiv-filter || exit 1
export OLLAMA_MODEL='qwen3.8:27b-q4_K_M'
export SLACK_WEBHOOK_URL='https://hooks.slack.com/services/...'
exec .venv/bin/python arxiv_local.py
Linux에서는 crontab -e에 아래처럼 등록한다. macOS에서는 같은 스크립트를 launchd로 매일 실행해도 된다.
0 9 * * 1-5 /absolute/path/arxiv-filter/run-local.sh >> /absolute/path/arxiv-filter/arxiv.log 2>&1
여기까지 확인 — ollama ps에 선별 모델이 올라오고, #arxiv-selected에 결과가 오며, 두 번째 실행에서는 같은 논문이 다시 올라오지 않는다.
함수 목록에서 collectArxiv를 선택해 실행한다. 첫 실행에는 외부 요청 권한 승인이 필요하다. Execution log에 오류가 없고 #arxiv-selected에 결과가 오면 API 호출과 Slack 전송이 모두 연결됐다.
여기까지 확인 — 선택된 논문마다 제목, URL, 선택 이유가 있고 초록에 없는 실험 결과나 수치가 붙지 않는다. API와 로컬 방식 모두 다시 실행했을 때 이미 처리한 논문이 반복해서 올라오지 않아야 한다.
Project Settings에서 time zone을 Asia/Seoul로 바꾼다. 왼쪽의 Triggers에서 collectArxiv를 선택하고 Time-driven → Day timer → 9am to 10am으로 저장한다. Apps Script의 일일 트리거는 정각이 아니라 선택한 한 시간 안에서 실행된다.
여기까지 확인 — 트리거 목록에 collectArxiv와 다음 실행 시간이 보인다. 다음 날 결과가 오지 않으면 Executions에서 API 응답 코드와 webhook 오류부터 확인한다.
하루 한 번 #arxiv-inbox와 #arxiv-selected를 비교한다. API 방식은 RESEARCH_PROFILE을, 로컬 방식은 research-profile.txt를 고친다. 놓친 논문은 포함 예시에, 잘못 고른 논문은 제외 예시에 보탠다. 분야가 너무 넓으면 /feed remove [ID]로 기존 피드를 지운 뒤 cs.RO처럼 좁은 주소로 다시 구독하고 RSS_URL도 같은 값으로 바꾼다.
완료 기준 — inbox에는 RSS 원문이 남고 selected에는 중복 없이 제목·링크·선택 이유가 올라온다. 이때부터 날짜별 읽기 목록이나 주간 연구 동향 요약을 사용 중인 Apps Script나 로컬 수집기에 붙이면 된다.
IEEE T-RO · T-IV · T-ASE · RA-L · RAS · Neurocomputing · ICRA · IROS · ICCAS · UR · IJCAS · JIST