1. なぜ掲示板ナビとして軽量AIを?
災害発生時の初期(数日間)の被災地・避難所の前提として完全オフライン状態であることを前提にした場合、どんなに外部との接続オプションがあっても宝の持ち腐れ状態を想定しておく必要があります。
TinyBox(仮)に完全オフラインでもローカルAIを搭載することで、掲示板から質問するとAIが返信する仕組みを構築し、避難所生活でのQOLを高め職員の補助役として負担軽減を担わせる役割の装置と位置付けてみました。
- 探す苦労」からの解放(精神的余裕)
避難所には大量の紙の掲示物が貼られますが、お年寄りや疲れ切った方にとって、その中から自分に必要な情報(例:自分の持病の薬があるか、など)を探し出すのは重労働です。
AIナビの役割: 掲示板に投稿された膨大な情報をAIが裏側でスキャンし、一言で「薬の相談は14時から体育館の奥です」と答えることで、情報の検索コストをゼロにします。
- 公平性の確保(情報のバリアフリー)
情報の伝達は、どうしても「放送を聞き逃した」「掲示板の近くにいた人だけが知っている」という格差を生みます。
AIナビの役割: 過去に流れた情報をAIが記憶しているため、いつ誰がアクセスしても「最新かつ正しい情報」を等しく手に入れることができます。
- 現場スタッフの負担軽減
「食事は何時?」「トイレはどこ?」といった繰り返される質問にAIが答えることで、スタッフはより専門的なケアや復旧作業に集中できます。
2. 使用するAIエンジンは?
AIエンジンは軽量LLMエンジンのllama.cppを使用しました。
特徴はこんな感じ。
- 驚異的な「軽量さ」と「移植性」
本来、AI(LLM)を動かすにはNVIDIAなどの高価で巨大なGPUが必要ですが、llama.cppはそれを「普通のCPU」で動かせるようにされています。
C/C++によるピュアな実装で外部ライブラリへの依存がほとんどなく、Raspberry PiのようなARM環境でも非常に高速かつ安定して動作し、独自の最適化により、省電力なチップでも実用的な速度で回答を生成できます。
- 量子化(Quantization)」技術の活用
巨大なAIモデルを「圧縮」して、メモリ容量が限られたデバイスに押し込む技術です。
メモリ節約: 数十GBあるモデルを数GBまで軽量化します。
実用性: 8GBや16GBのメモリしか積んでいないRaspberry Piでも、高性能なモデル(Llama 3やGemma 2など)を動かすことが可能になります。
- ハードウェア能力を限界まで引き出す
CPUだけでなく、デバイスが持つあらゆる計算資源を効率よく使います。
AVX/NEON 命令セットの活用: CPUの特殊な計算加速機能をフル活用します。
GPUオフロード: もしGPUが搭載されていれば、計算の一部をそちらに肩代わりさせてさらに高速化することも可能です。
- サーバー機能(llama-server)の内蔵
単体で「AIサーバー」として機能します。
WebブラウザやCGIから簡単にAIを呼び出せるインターフェースを標準で備えています。
外部のクラウドを通さないため、ローカルネットワーク(TinyBox(仮)内)で完結し、プライバシーとオフライン動作を担保し低遅延で動作します。
3. 使用するAIモデル
- モデルとは何か?
AIエンジン(llama.cpp)が「心臓」なら、モデル(Model)は「脳(知識と経験)」という位置づけです。
AIエンジンが空っぽの状態では何もできません(パソコンで言えばハード)。そこに「モデルファイル」(パソコンで言えばソフトウエア)を読み込ませることで、初めてAIとして機能します。
数千億枚の文書やウェブサイト、書籍を学習した結果、「ある言葉の次に、どの言葉が来るのが最も自然か」を予測するための膨大な数値データ(パラメータ)が詰まった一つのファイルです。
例えるなら、「超巨大な百科事典」と「精巧な翻訳機」が合体した、重さ数GBのデジタルな脳のようなものです。
- モデルの役割は?
TinyBox(仮)の中で、モデルは具体的に以下の3つの役割を担っています。
- 読解(Context Understanding):
CGIでAIに渡している「掲示板の過去ログ(事実)」を読み込み、何が起きているのかを理解します。
- 推論(Reasoning):
「ミルクはどこ?」という質問に対し、事実データの中から「受付で配布中」という関連性を見つけ出し、論理的に結びつけます。
- 生成(Text Generation):
抽出した情報を、避難者が読みやすい「自然な日本語」に変換して文章を作成します。
- 読解(Context Understanding):
そして、今回使用するモデルは、
- TinyLlama 1.1B Chat もしくは
- 日本語に強い Qwen2.5
必要RAM
約1.5GB
応答速度
Raspberry Pi5
約3〜5秒
という性能になります。
4. インストール
依存パッケージ
sudo apt update
sudo apt install -y git build-essential cmake python3 python3-pip
llama.cpp
git clone https://github.com/ggml-org/llama.cpp
cd llama.cpp
mkdir build
cd build
cmake ..
cmake --build . -j4
5. モデルをダウンロードする
ダウンロード例:Llama-1.1B-Chat-v1.0-GGUF
wget https://huggingface.co/TheBloke/TinyLlama-1.1B-Chat-v1.0-GGUF/resolve/main/tinyllama-1.1b-chat-v1.0.Q4_K_M.gguf
サイズ
約600MB
日本語に強いモデル
ダウンロード例:Qwen2.5-1.5B-Instruct-Q4_K_M.GGUF
wget https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF/resolve/main/qwen2.5-1.5b-instruct-q4_k_m.gguf?download=true
mv *qwen2.5-1.5b-instruct-q4_k_m.gguf* qwen2.5-1.5b.gguf
サイズ
1000MB(1GB)強
6. コマンドからのAI動作の確認
Llama-1.1B-Chat-v1.0-GGUFを使う場合
./bin/llama-cli \
-m tinyllama-1.1b-chat-v1.0.Q4_K_M.gguf \
-p "こんにちは"
Qwen2.5-1.5B-Instruct-Q4_K_M.GGUFを使う場合
./bin/llama-cli \
-m qwen2.5-1.5b.gguf \
-p "こんにちは"
起動の結果:
何かやたら出てきます。一瞬壊れているいるのかと思う位ですが正常動作のようです。
Loading model...
▄▄ ▄▄
██ ██
██ ██ ▀▀█▄ ███▄███▄ ▀▀█▄ ▄████ ████▄ ████▄
██ ██ ▄█▀██ ██ ██ ██ ▄█▀██ ██ ██ ██ ██ ██
██ ██ ▀█▄██ ██ ██ ██ ▀█▄██ ██ ▀████ ████▀ ████▀
██ ██
▀▀ ▀▀
build : b8298-f90bd1dd8
model : tinyllama-1.1b-chat-v1.0.Q4_K_M.gguf
modalities : text
available commands:
/exit or Ctrl+C stop or exit
/regen regenerate the last response
/clear clear the chat history
/read add a text file
> こんにちは
いつものご相談をご希望いただく際に、こんにちはをご使い上げることをお願いしています。また、こんにちはがお気に入りの言葉として、この拓哉のありの日常生活を描くところに感うと思うような、あまりにも頑張っていることをご謝り、質問いただいていませんか?
彼は世界において何姿が脱妖になるかと思うので、その世界にありますのでありがたく、お託めになります。
この世界において往返していることから、....
:
[ Prompt: 74.3 t/s | Generation: 12.9 t/s ]
>
7. 掲示板との連携
CURL例
curl http://localhost:8000/v1/chat/completions -H "Content-Type: application/json" -d '{
"model": "qwen2.5-1.5b.gguf",
"messages": [
{"role": "user", "content": "罹災証明"}
],
"stream": false
}'
8. systemd サービスファイル作成
sudo vi /etc/systemd/system/llama-server.service
[Unit]
Description=TinyLlama Server
After=network.target
[Service]
Type=simple
User=raspi5
WorkingDirectory=/home/raspi5/llama.cpp/build
ExecStart=/home/raspi5/llama.cpp/build/bin/llama-server \
-m /home/raspi5/llama.cpp/build/tinyllama-1.1b-chat-v1.0.Q4_K_M.gguf \
もしくは、
-m /home/raspi5/llama.cpp/build/qwen2.5-1.5b.gguf \
--port 8000
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
systemd に読み込ませる
sudo systemctl daemon-reload
起動テスト
sudo systemctl start llama-server
状態確認
sudo systemctl status llama-server
自動起動
sudo systemctl enable llama-server
動作確認
curl http://127.0.0.1:8000/v1/completions \
-H "Content-Type: application/json" \
-d '{"prompt":"テスト","n_predict":20}'
9. TinyBox(仮)構成
住民スマホ
│
│WiFi
▼
TinyBox(仮)
└掲示板
└ スクリプト
| SOCKET 127.0.0.1:8000
└AIエンジン
└ llama.cpp
└ TinyLlama
13. sbbs.cgiの改修
#!/bin/bash
# --- 1. 設定 ---
BASE_MAILDIR="/var/spool/epms/sbbs"
RULES_FILE="$BASE_MAILDIR/rules.txt"
QUESTIONS_FILE="$BASE_MAILDIR/questions.txt"
TYPE=$(echo "$QUERY_STRING" | sed -n 's/^.*type=\([^&]*\).*$/\1/p')
QUESTION=$(echo "$QUERY_STRING" | sed -n 's/^.*q=\([^&]*\).*$/\1/p' | python3 -c "import sys, urllib.parse; print(urllib.parse.unquote(sys.stdin.read()))" 2>/dev/null)
case "$TYPE" in
"radio") MAILDIR="$BASE_MAILDIR/radio"; TITLE="CH02: ミニラジオ"; THEME_COLOR="#00e676"; BG_GRADIENT="linear-gradient(135deg, #1b5e20, #00e676)" ;;
"video") MAILDIR="$BASE_MAILDIR/video"; TITLE="CH03: ミニ動画"; THEME_COLOR="#29b6f6"; BG_GRADIENT="linear-gradient(135deg, #01579b, #29b6f6)" ;;
"kids") MAILDIR="$BASE_MAILDIR/kids"; TITLE="CH04: キッズパーク"; THEME_COLOR="#ff9100"; BG_GRADIENT="linear-gradient(135deg, #e65100, #ffb74d)" ;;
*) MAILDIR="$BASE_MAILDIR/news"; TITLE="CH01: 総合掲示板"; TYPE="news"; THEME_COLOR="#3949ab"; BG_GRADIENT="linear-gradient(135deg, #1a237e, #3949ab)" ;;
esac
echo "Content-type: text/html; charset=UTF-8"
echo ""
# --- 2. AI解析(信頼性チェック強化) ---
export MAILDIR RULES_FILE QUESTION
# --- Python解析セクション(ここを差し替え) ---
export AI_RESULT=$(python3 << 'PYTHON_EOF'
import email, os, glob, requests, re, unicodedata
from email import policy
def get_answer():
try:
maildir = os.environ.get('MAILDIR', '')
q = os.environ.get('QUESTION', '')
if not q: return ""
# 1. 掲示板データの読み込み
out = []
files = sorted(glob.glob(os.path.join(maildir, '*.[Tt][Xx][Tt]')), key=os.path.getmtime, reverse=True)[:15]
for f in files:
try:
with open(f, 'rb') as fp:
msg = email.message_from_binary_file(fp, policy=policy.default)
subj = msg['subject'] or ''
p = msg.get_body(preferencelist=('plain',))
body = p.get_content() if p else ""
out.append(f"【{subj}】{body}".replace('\n', ' '))
except: continue
# 2. 質問から「検索キーワード」をAIに抽出させる(キーワード登録不要!)
# 1.1Bでも「単語だけ抜き出せ」なら得意です
extract_prompt = f"Extract search keywords from: {q}\nKeywords:"
try:
r_ext = requests.post("http://localhost:8000/completion",
json={"prompt": extract_prompt, "n_predict": 10, "stop": ["\n"]}, timeout=2)
# 抽出された単語(例:あそびば、オムツ)
kw = r_ext.json()['content'].strip()
except:
kw = q # 失敗したら質問文そのまま
# 3. 掲示板を「全検索」
# 漢字・ひらがな・カタカナの差を埋めるため、
# 質問の1文字目が含まれているだけでも候補にする
source = ""
for p in out:
# 抽出されたキーワードが含まれているか、
# もしくは質問の主要な2文字が含まれているか(強引ですが確実です)
if any(word in p for word in kw.split()) or (len(q) > 1 and q[:2] in p):
source = p[:300]
break
if not source:
# 最終手段:全データの中から「炊き出し」「物資」などの共通ワードがあるものを出す
source = next((p[:300] for p in out if any(w in p for w in ['配布', '開放', '開始', 'お知らせ'])), "")
if not source: return f"掲示板に「{q}」の情報は見つかりませんでした。"
# 4. AI推論 & 信頼性チェック(前回と同じ)
prompt = f"Data: {source}\nQ: {q}\nA:"
r = requests.post("http://localhost:8000/completion",
json={"prompt": prompt, "n_predict": 40, "temperature": 0.0, "stop": ["\n", "Data:"]}, timeout=4)
ans = r.json()['content'].strip()
if len(ans) > 5 and q not in ans:
return ans
return f"【情報】{source[:200]}..."
except: return "情報を確認中です。"
print(get_answer())
PYTHON_EOF
)
# --- 3. HTML出力 ---
cat <<EOF
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>$TITLE</title>
<style>
body { margin: 0; font-family: sans-serif; background: #f4f7f4; color: #333; }
header { background: $BG_GRADIENT; padding: 15px; color: white; box-shadow: 0 2px 5px rgba(0,0,0,0.2); }
.nav-menu { display: flex; background: #333; overflow-x: auto; white-space: nowrap; }
.nav-menu a { color: white; padding: 12px 18px; text-decoration: none; font-size: 0.9em; }
.nav-menu a.active { border-bottom: 3px solid $THEME_COLOR; background: rgba(255,255,255,0.1); }
.container { padding: 12px; max-width: 700px; margin: auto; }
#ai-popup { display: none; position: fixed; z-index: 9999; left: 0; top: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); align-items: center; justify-content: center; }
.popup-content { background: #fffde7; padding: 25px; border-radius: 20px; width: 85%; max-width: 450px; text-align: center; border: 4px solid #fbc02d; box-shadow: 0 10px 30px rgba(0,0,0,0.5); }
.popup-text { font-size: 1.2em; color: #d32f2f; font-weight: bold; line-height: 1.6; margin: 20px 0; text-align: left; }
.btn-close { background: #f57f17; color: white; border: none; padding: 12px 40px; border-radius: 8px; font-weight: bold; cursor: pointer; }
.ai-box { background: #fffde7; border: 2px solid #fbc02d; padding: 15px; border-radius: 12px; margin-bottom: 20px; display: flex; gap: 8px; }
.ai-box input { flex: 1; padding: 10px; border-radius: 8px; border: 1px solid #ccc; }
.btn-ask { background: #f57f17; color: white; border: none; padding: 0 15px; border-radius: 8px; font-weight: bold; }
.mail-card { background: white; margin-bottom: 10px; border-radius: 8px; border: 1px solid #ddd; overflow: hidden; }
.subject { font-weight: bold; padding: 12px; cursor: pointer; display: flex; justify-content: space-between; }
.body { display: none; padding: 12px; border-top: 1px solid #eee; background: #fafafa; white-space: pre-wrap; line-height: 1.5; }
</style>
<script>
function setQuestion(text) { document.getElementById('q-input').value = text; document.getElementById('q-form').submit(); }
function toggleCat(header) { header.classList.toggle('active'); var body = header.nextElementSibling; body.style.display = (body.style.display === 'block') ? 'none' : 'block'; }
function toggle(id){
var e = document.getElementById(id);
e.style.display = (e.style.display === 'block') ? 'none' : 'block';
}
function closePopup(){ document.getElementById('ai-popup').style.display = 'none'; }
window.onload = function() {
var txt = document.getElementById('ai-response-text').innerText.trim();
if(txt.length > 2) document.getElementById('ai-popup').style.display = 'flex';
}
</script>
</head>
<body>
<div id="ai-popup"><div class="popup-content">
<div style="font-weight:bold;color:#f57f17;">💡 TinyBox AIナビ</div>
<div id="ai-response-text" class="popup-text">$AI_RESULT</div>
<button class="btn-close" onclick="closePopup()">確認</button>
</div></div>
<header>
<div style="font-weight:bold; font-size:1.1em;">$TITLE</div>
</header>
<nav class="nav-menu">
<a href="sbbs.cgi?type=news" class="$( [[ "$TYPE" == "news" ]] && echo "active" )">🏠 総合</a>
<a href="sbbs.cgi?type=radio" class="$( [[ "$TYPE" == "radio" ]] && echo "active" )">📻 ラジオ</a>
<a href="sbbs.cgi?type=video" class="$( [[ "$TYPE" == "video" ]] && echo "active" )">📺 動画</a>
<a href="sbbs.cgi?type=kids" class="$( [[ "$TYPE" == "kids" ]] && echo "active" )">🧸 キッズ</a>
</nav>
<div class="container">
<div class="ai-box">
<form method="GET" style="display:contents;">
<input type="hidden" name="type" value="$TYPE">
<input type="text" id="q-input" name="q" placeholder="何かお困りですか?" value="$QUESTION">
<button type="submit" class="btn-ask">質問</button>
</form>
</div>
EOF
#echo "<div class='ai-box'>"
# --- 4. アコーディオンFAQ出力(カテゴリ単位で改行・縦並び) ---
if [ -f "$QUESTIONS_FILE" ]; then
CURRENT_CAT=""
# カテゴリ順にソートしてループ
sort -t',' -k1,1 "$QUESTIONS_FILE" | while IFS=, read -r cat text; do
[ -z "$cat" ] && continue
if [ "$cat" != "$CURRENT_CAT" ]; then
# 最初のカテゴリ以外は、前のカテゴリの閉じタグを出力
[ "$CURRENT_CAT" != "" ] && echo "</div></div>"
# 新しいカテゴリの開始(cat-sectionごとに改行されるようCSSで制御)
echo "<div class='cat-section' style='margin-bottom: 10px;'>"
echo " <div class='cat-header' onclick='toggleCat(this)' style='padding: 12px; background: #eee; cursor: pointer; border-radius: 8px;'>$cat</div>"
echo " <div class='faq-body' style='display:none; padding: 5px 0;'>"
CURRENT_CAT="$cat"
fi
# 質問ボタン(1行ずつ表示し、下に余白を追加)
echo " <div style='padding: 4px 0;'>"
echo " <button class='faq-btn' onclick=\"setQuestion('$text')\" style='display: block; width: 100%; text-align: left; padding: 12px; border: 1px solid #ddd; border-radius: 6px; background: white;'>$text</button>"
echo " </div>"
done
# 最後のカテゴリの閉じタグ
echo " </div></div>"
fi
#echo "</div>"
echo "<div class='section-title'>💬 最新の投稿</div>"
# --- 5. メールの表示ループ(復活版) ---
export MAILDIR
python3 << 'PYTHON_EOF'
import email, os, glob
from email import policy
maildir = os.environ.get('MAILDIR', '')
files = sorted(glob.glob(os.path.join(maildir, '*.[Tt][Xx][Tt]')), key=os.path.getmtime, reverse=True)[:15]
for i, f in enumerate(files):
try:
with open(f, 'rb') as fp:
msg = email.message_from_binary_file(fp, policy=policy.default)
subj = msg['subject'] or os.path.basename(f)
p = msg.get_body(preferencelist=('plain',))
body = p.get_content() if p else "本文なし"
print(f'<div class="mail-card">')
print(f' <div class="subject" onclick="toggle(\'msg-{i}\')"><span>{subj}</span><span>▼</span></div>')
print(f' <div id="msg-{i}" class="body">{body}</div>')
print(f'</div>')
except: continue
PYTHON_EOF
echo "</div></body></html>"
- 箱舟プロジェクト【防災通信】Raspberry Pi 4 + BC-11AH-A2(802.11ah HAT)セットアップ1(AP-STA)
- 箱舟プロジェクト【防災通信】Raspberry Pi 4 + BC-11AH-A2(802.11ah HAT)セットアップ2(MESH)
- 箱舟プロジェクト【防災通信】Raspberry Pi 4 + BC-11AH-A2(802.11ah HAT)セットアップ3(MESH自動化)
- 箱舟プロジェクト【防災通信】Raspberry Pi 4 + BC-11AH-A2(802.11ah HAT)セットアップ4TinyBox(仮)直結ゲートウェイ化
- 箱舟プロジェクト【防災通信】Raspberry Pi 4 + BC-11AH-A2(802.11ah HAT)セットアップ5TinyBox(仮)直結ゲートウェイ化)
- 箱舟プロジェクト【防災通信】Raspberry Pi 4 + BC-11AH-A2(802.11ah HAT)セットアップ6 TinyBox(仮)フィールドテスト
- 箱舟プロジェクト【防災通信】Raspberry Pi 4 + BC-11AH-A2(802.11ah HAT)セットアップ7 TinyBox(仮)メッシュノード増設接続
- 箱舟プロジェクト【防災通信】Raspberry Pi 4 + BC-11AH-A2(802.11ah HAT)セットアップ8 TinyBox(仮)外部接続
- 箱舟プロジェクト【防災通信】TinyBox ミニ・コンテンツサイト化を行ってみた。
- 箱舟プロジェクト【防災通信】フェーズフリー化 簡易掲示板
- 箱舟プロジェクト【防災通信】TinyBox(仮) 軽量AIを追加してしてみた。

