メールファイル( .msg )からのデータベース化、及び、閲覧ページ作成¶
基本構成¶
.msgで保存したメールデータを .json にデータベース化する.
.jsonで保存した情報を streamlit を介してWebブラウザから閲覧する.
メインページは一覧表示 ( app.py )
全文閲覧用に詳細表示ページを作成する ( 01_pageview.py )
コード¶
.mgs からの .json データベース化コード¶
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import os, re, argparse, json, datetime, tqdm
import shutil, logging
from pathlib import Path
from typing import List, Optional
import extract_msg # pip install extract-msg
from dateutil import parser as dtparser # pip install python-dateutil
from pydantic import BaseModel, Field, ValidationError
def suppress_google_genai_noise() -> None:
# この警告は google-genai が logging.warning で出してくることが多い
for name in ("google_genai.types", "google_genai", "google.genai"):
lg = logging.getLogger(name)
lg.setLevel(logging.ERROR) # WARNING を出さない
# どこかで basicConfig されてても確実に沈黙させたいなら propagate を切る
# lg.propagate = False
# -----------------------------
# 1) 出力スキーマ(Pydantic)
# -----------------------------
class MailExtraction(BaseModel):
# 「日付」: ニュースの発表日/出来事の日付を優先。無ければメール日付などから推定して YYYY-MM-DD、無理なら null。
date: Optional[str] = Field(
default=None,
description="ニュースとしての主要日付(YYYY-MM-DD)。不明なら null。"
)
company_names: List[str] = Field(
default_factory=list,
description="会社名(複数可)。例: [\"Novartis\", \"PharmaLogic\"]"
)
radionuclides: List[str] = Field(
default_factory=list,
description="放射性核種(複数可)。例: [\"Ac-225\", \"Lu-177\"]"
)
drug_names: List[str] = Field(
default_factory=list,
description="薬剤/候補薬(複数可)。例: [\"[Ac-225]-RTX-2358\"]"
)
english_news_article: Optional[str] = Field(
default=None,
description="メール本文中の英文ニュース記事(あれば原文を抽出、無ければ null)。"
)
japanese_translation: Optional[str] = Field(
default=None,
description="english_news_article の日本語訳(english_news_article が無いなら null)。"
)
summary: str = Field(
description="メール全体(日本語・英語含む)の要約(日本語)。"
)
impact_opinion: str = Field(
description="このニュースの影響・示唆(日本語、推測は推測とわかるように)。"
)
sender_opinion: Optional[str] = Field(
default=None,
description="送信者が冒頭で述べる所感・コメント(個人名/メール等は書かず、内容のみ)。無ければ null。"
)
title_en: str = Field(
description="英語タイトル。8単語以内。ハイフン連結は不要(Python側で行う)。"
)
# ★追加:日本語タイトル(ファイル名や title_en は変えず、タグとして保持)
title_ja: str = Field(
description="title_en に対応する、わかりやすい日本語タイトル。短く自然な見出し。"
)
# -----------------------------
# 2) .msg 読み取り
# -----------------------------
def read_msg(path: Path) -> dict:
"""
extract-msg で .msg から主要フィールドを抽出(process() は不要)。
"""
msg = extract_msg.openMsg(str(path))
try:
subject = (msg.subject or "").strip()
# plain body(無ければ "")
body = (msg.body or "").strip()
# plain が空で HTML がある場合のフォールバック(HTMLをそのまま入れる)
if (not body) and getattr(msg, "htmlBodyPrepared", None):
try:
body = msg.htmlBodyPrepared.decode("utf-8", errors="replace")
except Exception:
body = ""
# msg.date は datetime か None(send date)
raw_date = ""
iso_date: Optional[str] = None
if getattr(msg, "date", None):
try:
raw_date = str(msg.date)
iso_date = msg.date.date().isoformat()
except Exception:
raw_date = str(msg.date)
sender = (msg.sender or "").strip()
# 長すぎる本文の安全弁
MAX_CHARS = 80_000
if len(body) > MAX_CHARS:
body = body[:MAX_CHARS] + "\n\n[TRUNCATED]\n"
return {
"file_name": path.name,
"subject": subject,
"body": body,
"email_date_iso": iso_date,
"email_date_raw": raw_date,
"sender_raw": sender,
}
finally:
try:
msg.close()
except Exception:
pass
# -----------------------------
# 3) LLM に投げるプロンプト
# -----------------------------
def build_prompt(mail: dict) -> str:
"""
Structured Output 前提で、スキーマに沿う JSON を返すよう強く指示。
個人情報(個人名・メールアドレス等)は sender_opinion に含めない。
"""
instructions = f"""
あなたは放射性医薬品(Radiopharmaceuticals)のニュースを整理するアシスタントです。
以下の「メール」から情報を抽出し、指定スキーマに厳密に従う JSON だけを返してください(JSON以外は出力しない)。
重要ルール:
- "sender_opinion" には送信者が冒頭で述べる所感・コメントを要約して書く。
ただし個人情報は書かない(個人名、メールアドレス、電話番号、部署内の固有名詞などは削除/一般化)。
例: 「(同僚)」「(送信者)」のように匿名化する。
- "english_news_article" は、メール本文中に「ニュース記事としての英文」がまとまって存在する場合のみ、その英文部分を抽出する。
断片的な英語だけなら null でもよい。
- "japanese_translation" は english_news_article の忠実な日本語訳。english_news_article が null なら null。
- "date" はニュースとしての主要日付(プレスリリース日、治験開始/結果発表日など)を優先して YYYY-MM-DD で記載。
本文から判断できなければ、メール日付(参考: {mail.get("email_date_iso")})を使ってもよい。無理なら null。
- 不明な項目は、配列は []、文字列は null(ただし必須の summary/impact_opinion/title_en/title_ja は空でなく書く)。
- 会社名/核種/薬剤名は本文に出てくる表記を尊重(例: Ac-225, [Ac-225]-RTX-2358 など)。
- "title_en" は英語で8単語以内の短いタイトル(単語区切りのままでOK)。固有名詞は会社名/薬剤名は可。
例: "Ac-225 FAP radiopharmaceutical enters Phase 1/2"
- ★"title_ja" は title_en に対応する、わかりやすい日本語タイトル(短い見出し)。
直訳よりも自然さを優先してよいが、意味は title_en と整合させる。
- "english_news_article", "japanese_translation" は必ず 1 行の文字列にする(改行はスペースに置換)。
- "english_news_article", "japanese_translation" が長すぎる場合は最大6000文字で切り、末尾に " ...[TRUNCATED]" を付ける。
それでも収まりが悪い/不確実なら null にしてよい。
メール情報:
[SUBJECT]
{mail.get("subject")}
[EMAIL_DATE_RAW]
{mail.get("email_date_raw")}
[BODY]
{mail.get("body")}
""".strip()
return instructions
def _get_text_from_parts(resp) -> str:
"""
google-genai の resp.text を使わず、content.parts の text だけを連結して返す。
non-text part(thought_signature 等)が混ざっても警告を出さない。
"""
texts: list[str] = []
for cand in (getattr(resp, "candidates", None) or []):
content = getattr(cand, "content", None)
parts = getattr(content, "parts", None) or []
for p in parts:
t = getattr(p, "text", None)
if t:
texts.append(t)
return "".join(texts).strip()
def _squash_newlines(s: Optional[str]) -> Optional[str]:
"""CR/LF を除去して 1 行に潰す(空白は適度に正規化)"""
if s is None:
return None
# まず改行コードを統一
s = s.replace("\r\n", "\n").replace("\r", "\n")
# 改行をスペースに
s = re.sub(r"\s*\n\s*", " ", s)
# 連続スペースを1つに
s = re.sub(r"[ \t]{2,}", " ", s)
return s.strip()
# -----------------------------
# 4) JSON抽出の保険(まれにモデルが前後にゴミを付ける場合)
# -----------------------------
def _extract_json_fallback(text: str) -> str:
"""
まずは text 全体を JSON として扱う。
失敗したら、最初の { から最後の } を雑に抜き出す(最後の手段)。
"""
t = (text or "").strip()
if not t:
return t
# そのままJSONの可能性
try:
json.loads(t)
return t
except Exception:
pass
# ```json ... ``` の場合
m = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", t, flags=re.DOTALL | re.IGNORECASE)
if m:
cand = m.group(1).strip()
try:
json.loads(cand)
return cand
except Exception:
pass
# 最初の { から最後の } を抜き出し
i = t.find("{")
j = t.rfind("}")
if i != -1 and j != -1 and i < j:
cand = t[i : j + 1].strip()
try:
json.loads(cand)
return cand
except Exception:
pass
return t
# -----------------------------
# 5) Gemini 呼び出し(Structured Output)
# -----------------------------
def extract_with_gemini(
prompt: str,
model: str,
api_key: Optional[str] = None,
temperature: float = 0.0,
) -> MailExtraction:
"""
google-genai を使って JSON schema に沿う応答を生成し、Pydanticで検証。
"""
try:
from google import genai # pip install google-genai
except Exception as e:
raise RuntimeError(
"google-genai が import できません。Geminiモードを使う場合は `pip install google-genai` してください。"
) from e
if api_key:
client = genai.Client(api_key=api_key)
else:
# 環境変数 GEMINI_API_KEY / GOOGLE_API_KEY から読む
client = genai.Client()
schema = MailExtraction.model_json_schema()
resp = client.models.generate_content(
model=model,
contents=prompt,
config={
"temperature": float(temperature),
"response_mime_type": "application/json",
"response_json_schema": schema,
},
)
raw = _get_text_from_parts(resp) # resp.text -> function to load text
raw = _extract_json_fallback(raw)
try:
data = json.loads(raw)
except json.JSONDecodeError as e:
raise RuntimeError(
"Gemini returned invalid JSON (possibly truncated). "
f"JSONDecodeError: {e}\n\nRaw (head):\n{raw[:2000]}\n\nRaw (tail):\n{raw[-2000:]}"
) from e
# ★改行つぶし
data["english_news_article"] = _squash_newlines(data.get("english_news_article"))
data["japanese_translation"] = _squash_newlines(data.get("japanese_translation"))
try:
return MailExtraction.model_validate(data)
except ValidationError as e:
raise RuntimeError(
"Gemini response did not match schema after sanitizing.\n"
f"Validation error:\n{e}\n\nData(head):\n{json.dumps(data, ensure_ascii=False)[:2000]}"
) from e
# -----------------------------
# 6) Ollama 呼び出し(ローカル / Ollama Cloudでも可)
# -----------------------------
def extract_with_ollama(
prompt: str,
model: str,
host: Optional[str] = None,
temperature: float = 0.0,
) -> MailExtraction:
"""
Ollamaの structured outputs を使って JSON schema に沿う応答を生成し、Pydanticで検証。
- ローカル: Ollamaアプリ起動 + `ollama pull <model>` 済みで利用
- host を指定するとリモートOllamaにも接続可能(例: http://localhost:11434)
"""
try:
import ollama # pip install ollama
from ollama import Client
except Exception as e:
raise RuntimeError(
"ollama Python が import できません。Ollamaモードを使う場合は `pip install ollama` してください。"
) from e
schema = MailExtraction.model_json_schema()
if host:
client = Client(host=host)
resp = client.chat(
model=model,
messages=[{"role": "user", "content": prompt}],
format=schema, # ★ JSON Schemaを強制
options={"temperature": float(temperature)},
stream=False,
)
raw = resp.message.content
else:
resp = ollama.chat(
model=model,
messages=[{"role": "user", "content": prompt}],
format=schema, # ★ JSON Schemaを強制
options={"temperature": float(temperature)},
stream=False,
)
raw = resp.message.content if hasattr(resp, "message") else resp["message"]["content"]
raw = _extract_json_fallback(raw)
try:
return MailExtraction.model_validate_json(raw)
except ValidationError as e:
raise RuntimeError(
"Ollama response did not match schema. "
f"Raw response text:\n{raw}\n\nValidation error:\n{e}"
)
# -----------------------------
# 7) 入出力(単体/ディレクトリ対応)
# -----------------------------
def iter_msg_files(input_path: Path) -> List[Path]:
if input_path.is_file():
return [input_path]
processed_dir = input_path / "processed"
files: List[Path] = []
for p in input_path.rglob("*.msg"):
if not p.is_file():
continue
# processed/ 配下は除外(再処理防止)
try:
if p.is_relative_to(processed_dir):
continue
except AttributeError:
# Python < 3.9
try:
p.relative_to(processed_dir)
continue
except ValueError:
pass
files.append(p)
return sorted(files)
def ensure_out_path(out: Path, input_is_dir: bool) -> None:
if input_is_dir:
out.mkdir(parents=True, exist_ok=True)
else:
out.parent.mkdir(parents=True, exist_ok=True)
def _yyyymmdd_from_dates(primary_iso: str | None, fallback_iso: str | None) -> str:
"""
YYYYMMDD を作る。primary_iso 優先、無ければ fallback_iso、無ければ今日。
"""
for iso in (primary_iso, fallback_iso):
if iso:
try:
d = dtparser.parse(iso).date()
return d.strftime("%Y%m%d")
except Exception:
pass
return datetime.date.today().strftime("%Y%m%d")
def _slug_from_title_en(title_en: str) -> str:
"""
英語タイトル(単語区切り)→ 8単語に制限 → '-' 連結 → ファイル名に安全な slug 化
"""
words = re.findall(r"[A-Za-z0-9\[\]\(\)\+\.]+(?:-[A-Za-z0-9\[\]\(\)\+\.]+)?", title_en)
words = words[:8] if words else []
slug = "-".join(words).strip("-").lower()
slug = re.sub(r"[^a-z0-9\-\[\]\(\)\+\.]+", "-", slug)
slug = re.sub(r"-{2,}", "-", slug).strip("-")
return slug or "news-update"
def build_final_title(result_date_iso: str | None, email_date_iso: str | None, title_en: str) -> str:
yyyymmdd = _yyyymmdd_from_dates(result_date_iso, email_date_iso)
slug = _slug_from_title_en(title_en)
return f"{yyyymmdd}-{slug}"
def unique_path(base: Path) -> Path:
"""
既に存在する場合は -2, -3... を付けてユニークにする
"""
if not base.exists():
return base
stem = base.stem
suffix = base.suffix
parent = base.parent
i = 2
while True:
cand = parent / f"{stem}-{i}{suffix}"
if not cand.exists():
return cand
i += 1
def move_to_bucket(msg_file: Path, in_path: Path, input_is_dir: bool, bucket: str) -> Path:
"""
msg_file を processed_<bucket>/ 配下に移動する。
bucket 例: "ok", "failed"
"""
if input_is_dir:
root = in_path / f"processed_{bucket}"
rel = msg_file.relative_to(in_path)
dst = root / rel
else:
root = in_path.parent / f"processed_{bucket}"
dst = root / msg_file.name
root.mkdir(parents=True, exist_ok=True)
dst.parent.mkdir(parents=True, exist_ok=True)
dst = unique_path(dst)
shutil.move(str(msg_file), str(dst))
return dst
def main():
ap = argparse.ArgumentParser()
ap.add_argument(
"-i", "--input", type=str, default="msg",
help="入力 .msg ファイル、または .msg を含むディレクトリ"
)
ap.add_argument(
"-o", "--output", type=str, default="json",
help="出力先(省略時: ./json)。単体入力なら親ディレクトリとして扱い title.json を作成"
)
# ★追加:バックエンド切替
ap.add_argument(
"--backend", type=str, default="gemini", choices=["gemini", "ollama"],
help="使用するLLMバックエンド(gemini: API / ollama: ローカルLLM)"
)
# Gemini用
ap.add_argument(
"--model", type=str, default="gemini-flash-latest",
help="Gemini model name (default: gemini-flash-latest)"
)
ap.add_argument(
"--api-key", type=str, default=None,
help="Gemini APIキーを直接渡す(通常は環境変数 GEMINI_API_KEY 推奨)"
)
# Ollama用
ap.add_argument(
"--ollama-model", type=str, default="gemma3",
help="Ollama model name (default: gemma3). 例: gemma3, llama3.2, qwen2.5, etc."
)
ap.add_argument(
"--ollama-host", type=str, default=None,
help="Ollama host (例: http://localhost:11434). 未指定なら環境変数 OLLAMA_HOST / デフォルトを使用"
)
# 共通
ap.add_argument(
"--temperature", type=float, default=0.0,
help="生成温度(structured outputでは 0 推奨)"
)
ap.add_argument(
"--dry-run", action="store_true",
help="LLMには投げず、抽出対象テキスト確認のみ行う"
)
args = ap.parse_args()
in_path = Path(args.input).expanduser().resolve()
out_path = Path(args.output).expanduser().resolve()
msg_files = iter_msg_files(in_path)
if not msg_files:
raise SystemExit(f"No .msg files found: {in_path}")
suppress_google_genai_noise()
input_is_dir = in_path.is_dir()
ensure_out_path(out_path, input_is_dir=input_is_dir)
# ログファイルは out_path の解釈に合わせる(out_pathがdirならそこ、fileなら親)
log_dir = out_path if (input_is_dir or out_path.is_dir() or str(out_path).endswith(os.sep)) else out_path.parent
log_dir.mkdir(parents=True, exist_ok=True)
log_file = log_dir / "msg2json.log"
logger = logging.getLogger("msg2json")
logger.setLevel(logging.INFO)
logger.handlers.clear()
fh = logging.FileHandler(log_file, encoding="utf-8")
fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
logger.addHandler(fh)
logger.propagate = False
ok_count = 0
fail_count = 0
for msg_file in tqdm.tqdm(msg_files):
try:
mail = read_msg(msg_file)
if args.dry_run:
print(f"--- {msg_file} ---")
print("SUBJECT:", mail["subject"])
print("EMAIL_DATE_RAW:", mail["email_date_raw"])
print("BODY(head):", mail["body"][:500])
continue
prompt = build_prompt(mail)
# ===== LLM 呼び出し(ここが一番落ちる)=====
try:
if args.backend == "gemini":
result = extract_with_gemini(
prompt,
model=args.model,
api_key=args.api_key,
temperature=args.temperature,
)
else:
result = extract_with_ollama(
prompt,
model=args.ollama_model,
host=args.ollama_host,
temperature=args.temperature,
)
except Exception as e:
fail_count += 1
logger.exception(f"[FAILED] LLM parse error: {msg_file} : {e}")
# 失敗した msg は再処理を避けるため退避
try:
moved = move_to_bucket(msg_file, in_path, input_is_dir, bucket="failed")
logger.info(f"[FAILED] Moved: {msg_file} -> {moved}")
except Exception as me:
logger.exception(f"[FAILED] Could not move failed msg: {msg_file} : {me}")
continue # ★次へ
# ===== ここから先は「結果の保存・移動」=====
payload = result.model_dump()
final_title = build_final_title(
result_date_iso=payload.get("date"),
email_date_iso=mail.get("email_date_iso"),
title_en=payload.get("title_en", ""),
)
payload["title"] = final_title
if input_is_dir or out_path.is_dir() or str(out_path).endswith(os.sep):
out_dir = out_path
out_dir.mkdir(parents=True, exist_ok=True)
else:
out_dir = out_path.parent
out_dir.mkdir(parents=True, exist_ok=True)
dst = unique_path(out_dir / f"{final_title}.json")
dst.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
logger.info(f"[OK] Wrote: {dst}")
# 成功した msg を processed_ok へ
try:
moved = move_to_bucket(msg_file, in_path, input_is_dir, bucket="ok")
logger.info(f"[OK] Moved: {msg_file} -> {moved}")
except Exception as me:
# JSONは書けたが msg 移動に失敗、という状態なので止めずにログだけ
logger.exception(f"[OK] Could not move msg (json already written): {msg_file} : {me}")
ok_count += 1
except KeyboardInterrupt:
raise
except Exception as e:
# read_msg など「想定外」で落ちた場合もスキップして続行
fail_count += 1
logger.exception(f"[FAILED] Unexpected error: {msg_file} : {e}")
try:
moved = move_to_bucket(msg_file, in_path, input_is_dir, bucket="failed")
logger.info(f"[FAILED] Moved: {msg_file} -> {moved}")
except Exception as me:
logger.exception(f"[FAILED] Could not move failed msg: {msg_file} : {me}")
continue
logger.info(f"Done. ok={ok_count}, failed={fail_count}, total={len(msg_files)}")
# for msg_file in tqdm.tqdm( msg_files ):
# mail = read_msg(msg_file)
# if args.dry_run:
# print(f"--- {msg_file} ---")
# print("SUBJECT:", mail["subject"])
# print("EMAIL_DATE_RAW:", mail["email_date_raw"])
# print("BODY(head):", mail["body"][:500])
# continue
# prompt = build_prompt(mail)
# # ★ここでバックエンド分岐
# if args.backend == "gemini":
# result = extract_with_gemini(
# prompt,
# model=args.model,
# api_key=args.api_key,
# temperature=args.temperature,
# )
# else:
# result = extract_with_ollama(
# prompt,
# model=args.ollama_model,
# host=args.ollama_host,
# temperature=args.temperature,
# )
# payload = result.model_dump()
# # title を最終生成(YYYYMMDD- + 英語8単語以内を'-'連結)
# final_title = build_final_title(
# result_date_iso=payload.get("date"),
# email_date_iso=mail.get("email_date_iso"),
# title_en=payload.get("title_en", ""),
# )
# payload["title"] = final_title # ← JSON に含める(要件)
# # 出力先の解釈:
# # - 入力がディレクトリなら out_path はディレクトリ扱い
# # - 入力が単体でも、out_path がディレクトリならそこへ title.json
# # - out_path がファイルパスでも、要件に従い parent に title.json を作る
# if input_is_dir or out_path.is_dir() or str(out_path).endswith(os.sep):
# out_dir = out_path
# out_dir.mkdir(parents=True, exist_ok=True)
# else:
# out_dir = out_path.parent
# out_dir.mkdir(parents=True, exist_ok=True)
# dst = out_dir / f"{final_title}.json"
# dst = unique_path(dst)
# dst.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
# # ★標準出力ではなくログへ
# logger.info(f"Wrote: {dst}")
# # ★処理済みファイルを processed/ へ移動(同じファイルが何度も対象になるのを防ぐ)
# if input_is_dir:
# processed_root = in_path / "processed"
# rel = msg_file.relative_to(in_path) # 元の相対パスを維持
# moved_dst = processed_root / rel # processed/<元の相対パス>
# else:
# processed_root = in_path.parent / "processed"
# moved_dst = processed_root / msg_file.name
# processed_root.mkdir(parents=True, exist_ok=True)
# moved_dst.parent.mkdir(parents=True, exist_ok=True)
# moved_dst = unique_path(moved_dst) # 同名衝突回避
# shutil.move(str(msg_file), str(moved_dst))
# logger.info(f"Moved: {msg_file} -> {moved_dst}")
if __name__ == "__main__":
main()
streamlit 閲覧ページ生成コード¶
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import html
import json
from pathlib import Path
from typing import Any, Dict, List, Optional
import pandas as pd
import streamlit as st
# ★ ステータス(記事ページ側と揃える)
ALLOWED_STATUS = ["New", "既読", "要再確認"]
# 目次表でのプレビュー量(文字数)
PREVIEW_MAX_CHARS = 2600
def safe_load_json(p: Path) -> Optional[Dict[str, Any]]:
try:
return json.loads(p.read_text(encoding="utf-8"))
except Exception:
return None
def ensure_user_fields(d: Dict[str, Any]) -> Dict[str, Any]:
# 追記欄の既定値だけ保証
if "user_comment" not in d:
d["user_comment"] = ""
if "user_rating" not in d:
d["user_rating"] = None
if "user_status" not in d:
d["user_status"] = "New"
if "user_updated_at" not in d:
d["user_updated_at"] = None
# 既存データ互換(旧: new/reviewed/shared/ignored → 新: New/既読/要再確認)
old = str(d.get("user_status") or "")
mapping = {
"new": "New",
"reviewed": "既読",
"shared": "既読",
"ignored": "要再確認",
}
if old in mapping:
d["user_status"] = mapping[old]
if d.get("user_status") not in ALLOWED_STATUS:
d["user_status"] = "New"
# ratingをintに正規化
r = d.get("user_rating")
if r is not None:
try:
r = int(r)
d["user_rating"] = r if 1 <= r <= 5 else None
except Exception:
d["user_rating"] = None
return d
def join_list(x: Any) -> str:
if not x:
return ""
if isinstance(x, list):
return ", ".join(str(i) for i in x)
return str(x)
def normalize_text(x: Any) -> str:
if not x:
return ""
return str(x).replace("\r\n", "\n").replace("\r", "\n")
def preview_text(x: Any) -> str:
t = " ".join(normalize_text(x).split())
if len(t) > PREVIEW_MAX_CHARS:
t = t[:PREVIEW_MAX_CHARS] + "…"
return t
def title_of(d: Dict[str, Any]) -> str:
return str(d.get("title") or d.get("subject") or d.get("file_name") or "untitled")
def parse_date_for_sort(d: Dict[str, Any]) -> pd.Timestamp:
for k in ["date", "email_date_iso"]:
v = d.get(k)
if v:
try:
return pd.to_datetime(v, errors="raise")
except Exception:
pass
return pd.Timestamp.min
def rating_to_stars(r: Any) -> str:
"""目次表示専用:★のみ(☆☆なし)"""
if r is None or (isinstance(r, float) and pd.isna(r)):
return ""
try:
rr = int(r)
except Exception:
return ""
rr = max(0, min(5, rr))
return "★" * rr if rr > 0 else ""
def list_json_files(root: Path) -> List[Path]:
return sorted([p for p in root.rglob("*.json") if p.is_file()])
@st.cache_data(show_spinner=False)
def load_index(json_dir: str) -> pd.DataFrame:
root = Path(json_dir).expanduser().resolve()
rows = []
for p in list_json_files(root):
d = safe_load_json(p)
if not isinstance(d, dict):
continue
d = ensure_user_fields(d)
rows.append({
"_path": str(p),
"_sort_date": parse_date_for_sort(d),
# 並び順は後でcolsで制御するので、ここは全項目を持つ
"No.": None, # 後で採番
"日付": d.get("date") or "",
"タイトル": title_of(d),
"リンク": "", # HTML生成時に作る(ここはダミー)
"状態": d.get("user_status") or "New",
"重要度": rating_to_stars(d.get("user_rating")),
"要約": preview_text(d.get("summary")),
"会社": join_list(d.get("company_names")),
"核種": join_list(d.get("radionuclides")),
"薬剤": join_list(d.get("drug_names")),
"コメント": preview_text(d.get("user_comment")),
"更新日時": d.get("user_updated_at") or "",
"AI影響評価": preview_text(d.get("impact_opinion")),
"英文": preview_text(d.get("english_news_article")),
"和訳": preview_text(d.get("japanese_translation")),
"他者コメント": preview_text(d.get("sender_opinion")),
})
df = pd.DataFrame(rows)
if len(df) == 0:
return df
df = df.sort_values(by=["_sort_date", "タイトル"], ascending=[False, True], kind="stable").reset_index(drop=True)
df["No."] = range(1, len(df) + 1)
return df
def render_html_table(df: pd.DataFrame, clamp_lines: int) -> None:
st.markdown(f"""
<style>
.table-wrap {{
overflow-x: auto;
padding-bottom: 8px;
}}
.news-table {{
border-collapse: separate;
border-spacing: 0 10px;
width: max-content;
font-size: 0.80rem;
line-height: 1.20;
}}
.news-table tbody tr {{
background: rgba(255,255,255,0.03);
outline: 1px solid rgba(255,255,255,0.08);
border-radius: 10px;
}}
.news-table td, .news-table th {{
padding: 8px 10px;
vertical-align: top;
border: none;
}}
.news-table thead th {{
position: sticky;
top: 0;
background: rgba(0,0,0,0.60);
z-index: 5;
outline: 1px solid rgba(255,255,255,0.12);
border-radius: 8px;
}}
.clamp {{
display: -webkit-box;
-webkit-line-clamp: {clamp_lines};
-webkit-box-orient: vertical;
overflow: hidden;
white-space: normal;
}}
.title {{
font-weight: 400;
white-space: normal;
}}
a {{
text-decoration: none;
}}
a:hover {{
text-decoration: underline;
}}
.goto a {{
display: inline-block;
padding: 4px 8px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.18);
background: rgba(100,200,255,0.10);
}}
.goto a:hover {{
background: rgba(100,200,255,0.18);
}}
/* 列幅(メタ列は細く) */
.w-no {{ min-width: 46px; max-width: 60px; }}
.w-date {{ min-width: 80px; max-width: 110px; }}
.w-title {{ min-width: 260px; max-width: 520px; }}
.w-link {{ min-width: 70px; max-width: 90px; }}
.w-state {{ min-width: 70px; max-width: 110px; }}
.w-rate {{ min-width: 70px; max-width: 110px; }}
.w-summary{{ min-width: 520px; max-width: 860px; }}
.w-company{{ min-width: 120px; max-width: 200px; }}
.w-nuclide{{ min-width: 90px; max-width: 140px; }}
.w-drug {{ min-width: 120px; max-width: 200px; }}
.w-comment{{ min-width: 360px; max-width: 620px; }}
.w-upd {{ min-width: 140px; max-width: 220px; }}
.w-impact {{ min-width: 520px; max-width: 860px; }}
.w-en {{ min-width: 520px; max-width: 860px; }}
.w-ja {{ min-width: 520px; max-width: 860px; }}
.w-other {{ min-width: 520px; max-width: 860px; }}
</style>
""", unsafe_allow_html=True)
# ★ 指定の列順
cols = [
("No.", "w-no"),
("日付", "w-date"),
("タイトル", "w-title"),
("リンク", "w-link"),
("状態", "w-state"),
("重要度", "w-rate"),
("要約", "w-summary"),
("会社", "w-company"),
("核種", "w-nuclide"),
("薬剤", "w-drug"),
("コメント", "w-comment"),
("更新日時", "w-upd"),
("AI影響評価", "w-impact"),
("英文", "w-en"),
("和訳", "w-ja"),
("他者コメント", "w-other"),
]
thead = "<tr>" + "".join(
f"<th class='{cls}'>{html.escape(name)}</th>" for name, cls in cols
) + "</tr>"
def cell(text: Any, cls: str, clamp: bool = True, extra: str = "") -> str:
s = "" if pd.isna(text) else str(text)
s = html.escape(s)
c = "clamp " if clamp else ""
return f"<td class='{cls}'><div class='{c}{extra}'>{s}</div></td>"
rows_html = []
for _, r in df.iterrows():
p = str(r["_path"])
href = f"/pageview?id={html.escape(p)}"
row = "<tr>"
row += cell(r["No."], "w-no", clamp=False)
row += cell(r["日付"], "w-date", clamp=False)
row += cell(r["タイトル"], "w-title", clamp=True, extra="title")
# リンク列(表示文字は「リンク」固定)
row += f"<td class='w-link goto'><a href='{href}' target='_self'>リンク</a></td>"
row += cell(r["状態"], "w-state", clamp=False)
row += cell(r["重要度"], "w-rate", clamp=False)
row += cell(r["要約"], "w-summary", clamp=True)
row += cell(r["会社"], "w-company", clamp=True)
row += cell(r["核種"], "w-nuclide", clamp=True)
row += cell(r["薬剤"], "w-drug", clamp=True)
row += cell(r["コメント"], "w-comment", clamp=True)
row += cell(r["更新日時"], "w-upd", clamp=False)
row += cell(r["AI影響評価"], "w-impact", clamp=True)
row += cell(r["英文"], "w-en", clamp=True)
row += cell(r["和訳"], "w-ja", clamp=True)
row += cell(r["他者コメント"], "w-other", clamp=True)
row += "</tr>"
rows_html.append(row)
table_html = f"""
<div class="table-wrap">
<table class="news-table">
<thead>{thead}</thead>
<tbody>{''.join(rows_html)}</tbody>
</table>
</div>
"""
st.markdown(table_html, unsafe_allow_html=True)
# ========= UI =========
st.set_page_config(page_title="核医学の情報収集ログ", layout="wide")
st.title("核医学の情報収集ログ")
with st.sidebar:
json_dir = st.text_input("JSONフォルダ(out/など)", value="json")
if st.button("再読み込み", use_container_width=True):
load_index.clear()
st.divider()
st.subheader("フィルタ")
q = st.text_input("検索(タイトル/会社/核種/薬剤/要約/コメント/AI影響/英文/和訳/他者コメント)", value="")
status_filter = st.multiselect("状態(user_status)", options=ALLOWED_STATUS, default=[])
# 重要度は★表示でフィルタ
rating_filter = st.multiselect("重要度(★)", options=[1, 2, 3, 4, 5], default=[])
st.divider()
st.subheader("表示")
clamp_lines = st.slider("本文プレビュー行数(目次)", min_value=3, max_value=14, value=8, step=1)
df = load_index(json_dir)
if df is None or len(df) == 0:
st.warning("JSONが見つかりません。フォルダ指定を確認してください。")
st.stop()
def match_row(r: pd.Series) -> bool:
if status_filter and r["状態"] not in status_filter:
return False
if rating_filter:
# df側は ★文字列なので、その数で判定
star_n = str(r.get("重要度", "")).count("★")
if star_n not in rating_filter:
return False
if q.strip():
qq = q.strip().lower()
hay = " ".join([
str(r.get("タイトル", "")),
str(r.get("会社", "")),
str(r.get("核種", "")),
str(r.get("薬剤", "")),
str(r.get("要約", "")),
str(r.get("コメント", "")),
str(r.get("状態", "")),
str(r.get("重要度", "")),
str(r.get("AI影響評価", "")),
str(r.get("英文", "")),
str(r.get("和訳", "")),
str(r.get("他者コメント", "")),
]).lower()
if qq not in hay:
return False
return True
df_view = df[df.apply(match_row, axis=1)].copy()
st.caption(f"表示: {len(df_view)} / 全体: {len(df)}")
# 全件表示(ページングなし)
if len(df_view) == 0:
st.info("フィルタ結果が0件です。")
st.stop()
render_html_table(df_view, clamp_lines=clamp_lines)
# --- ページングあり --- #
# total = len(df_view)
# if total == 0:
# st.info("フィルタ結果が0件です。")
# st.stop()
# max_page = (total - 1) // rows_per_page + 1
# page = st.number_input("ページ", min_value=1, max_value=max_page, value=1, step=1)
# start = (page - 1) * rows_per_page
# end = min(start + rows_per_page, total)
# df_page = df_view.iloc[start:end].copy()
# render_html_table(df_page, clamp_lines=clamp_lines)
streamlit 詳細ページ用 (部品) コード¶
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import json
import shutil
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Optional
import streamlit as st
# ステータス(指定の3種)
ALLOWED_STATUS = ["New", "既読", "要再確認"]
DEFAULTS = {
"user_comment": "",
"user_rating": None, # 1-5 or None(JSON保存はint)
"user_status": "New",
"user_updated_at": None,
}
def now_iso_jst() -> str:
return datetime.now().strftime("%Y-%m-%dT%H:%M:%S+09:00")
def safe_load_json(p: Path) -> Optional[Dict[str, Any]]:
try:
return json.loads(p.read_text(encoding="utf-8"))
except Exception:
return None
def ensure_user_fields(d: Dict[str, Any]) -> Dict[str, Any]:
for k, v in DEFAULTS.items():
if k not in d:
d[k] = v
if d.get("user_status") not in ALLOWED_STATUS:
d["user_status"] = "New"
r = d.get("user_rating")
if r is not None:
try:
r = int(r)
d["user_rating"] = r if 1 <= r <= 5 else None
except Exception:
d["user_rating"] = None
return d
def join_list(x: Any) -> str:
if not x:
return ""
if isinstance(x, list):
return ", ".join(str(i) for i in x)
return str(x)
def title_of(d: Dict[str, Any]) -> str:
return str(d.get("title") or d.get("subject") or d.get("file_name") or "untitled")
def rating_to_stars(r: Optional[int]) -> str:
"""表示専用:★のみ(空き☆は出さない)"""
if r is None:
return "—"
try:
r = int(r)
except Exception:
return "—"
r = max(0, min(5, r))
return "★" * r if r > 0 else "—"
def stars_to_int(s: str) -> Optional[int]:
"""UI選択(★表記)→ intへ"""
if s == "(なし)":
return None
return s.count("★")
def get_app_root() -> Path:
"""
app.py と同階層を “home” とみなす。
pages/01_pageview.py を想定して、親の親がルート。
"""
here = Path(__file__).resolve()
return here.parents[1]
def backup_file(src: Path) -> Path:
backup_dir = get_app_root() / "backup"
backup_dir.mkdir(parents=True, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
dst = backup_dir / f"{src.stem}__{ts}{src.suffix}"
shutil.copy2(src, dst)
return dst
def save_record(path: Path, d: Dict[str, Any]) -> None:
backup_file(path)
d["user_updated_at"] = now_iso_jst()
path.write_text(json.dumps(d, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def get_id_param() -> Optional[str]:
if hasattr(st, "query_params"):
v = st.query_params.get("id")
return v if isinstance(v, str) else (v[0] if v else None)
qp = st.experimental_get_query_params()
return qp.get("id", [None])[0]
# ========= UI =========
st.set_page_config(page_title="核医学の情報収集ログ - 記事", layout="wide")
st.title("核医学の情報収集ログ")
st.page_link("app.py", label="← 目次に戻る")
id_path = get_id_param()
if not id_path:
st.warning("記事ID(id)がありません。目次から開いてください。")
st.stop()
path = Path(id_path)
rec = safe_load_json(path)
if not isinstance(rec, dict):
st.error(f"JSONを読めません: {id_path}")
st.stop()
rec = ensure_user_fields(rec)
st.markdown(f"## {title_of(rec)}")
st.write(f"**日付:** {rec.get('date') or '-'}")
st.write(f"**会社:** {join_list(rec.get('company_names')) or '-'}")
st.write(f"**核種:** {join_list(rec.get('radionuclides')) or '-'}")
st.write(f"**薬剤:** {join_list(rec.get('drug_names')) or '-'}")
st.write(f"**状態:** {rec.get('user_status') or 'New'}")
st.write(f"**重要度:** {rating_to_stars(rec.get('user_rating'))}")
st.caption(f"JSON: {id_path}")
with st.expander("要約", expanded=True):
st.write(rec.get("summary") or "")
with st.expander("AI影響コメント", expanded=True):
st.write(rec.get("impact_opinion") or "")
if rec.get("sender_opinion"):
with st.expander("他者コメント", expanded=False):
st.write(rec.get("sender_opinion") or "")
with st.expander("英文", expanded=False):
st.write(rec.get("english_news_article") or "")
with st.expander("和訳", expanded=False):
st.write(rec.get("japanese_translation") or "")
st.subheader("評価メモ")
with st.form("edit_form", clear_on_submit=False):
c1, c2, c3 = st.columns([0.34, 0.22, 0.44])
with c1:
user_status = st.selectbox(
"状態",
options=ALLOWED_STATUS,
index=ALLOWED_STATUS.index(rec.get("user_status") or "New"),
)
with c2:
# UIは★のみ。保存はint。
rating_options = ["(なし)"] + ["★" * i for i in range(1, 6)]
cur = rec.get("user_rating")
idx = 0 if cur is None else max(1, min(5, int(cur)))
rating_sel = st.selectbox("重要度", options=rating_options, index=idx)
with c3:
st.text_input("更新日時", value=rec.get("user_updated_at") or "-", disabled=True)
user_comment = st.text_area("コメント", value=rec.get("user_comment") or "", height=180)
submitted = st.form_submit_button("保存(.jsonに追記)")
if submitted:
rec["user_status"] = user_status
rec["user_rating"] = stars_to_int(rating_sel) # int/Noneで保存
rec["user_comment"] = user_comment or ""
save_record(path, rec)
st.cache_data.clear() # ★ 目次の load_index キャッシュを無効化
st.success("Saved.")
実行用 tasks.py¶
# tasks.py
# Invoke tasks for msg2json
#
# Usage examples:
# invoke msg2json
# invoke msg2json --input="msg" --output="json"
# invoke msg2json --input="some/file.msg" --output="json" --dry-run
# invoke msg2json --model="gemini-flash-latest"
# invoke msg2json --api-key="YOUR_KEY"
#
# Notes:
# - This assumes your converter script is named "msg2json.py" in the same directory.
# If your filename is different, pass --script="yourname.py" or change DEFAULT_SCRIPT below.
from __future__ import annotations
import os
import shlex
from invoke import task
DEFAULT_SCRIPT = "pyt/msg2json.py"
DEFAULT_APP = "app.py"
def _q(s: str) -> str:
"""Shell-quote helper."""
return shlex.quote(s)
@task(
help={
"input": "Input .msg file or directory (default: msg)",
"output": "Output directory or path (default: json)",
"model": "Gemini model name (default: gemini-flash-latest)",
"api_key": "API key (optional; otherwise rely on env GEMINI_API_KEY/GOOGLE_API_KEY)",
"dry_run": "If set, do not call Gemini; just print extracted mail head/body preview",
"script": "Python script filename to run (default: msg2json.py)",
"python": "Python executable to use (default: python3 or $PYTHON)",
"extra": "Extra raw CLI args appended to the command (optional)",
}
)
def msg2json(
c,
input: str = "msg",
output: str = "json",
model: str = "gemini-flash-latest",
api_key: str | None = None,
dry_run: bool = False,
script: str = DEFAULT_SCRIPT,
python: str | None = None,
extra: str | None = None,
):
"""
Run msg2json converter via Invoke.
This calls:
python msg2json.py -i <input> -o <output> --model <model> [--api-key ...] [--dry-run]
"""
py = python or os.environ.get("PYTHON") or "python3"
cmd_parts = [
_q(py),
_q(script),
"-i",
_q(input),
"-o",
_q(output),
"--model",
_q(model),
]
if api_key:
cmd_parts += ["--api-key", _q(api_key)]
if dry_run:
cmd_parts.append("--dry-run")
if extra:
# Append as-is (user responsibility). If you want safe splitting, pass it like:
# invoke msg2json --extra='--dry-run'
cmd_parts.append(extra)
cmd = " ".join(cmd_parts)
c.run(cmd, pty=True)
@task(
help={
"app": "Streamlit app file (default: app.py)",
"port": "Server port (default: 8501)",
"server_headless": "Run Streamlit in headless mode (useful on servers/CI)",
"browser": "Open browser automatically (default: False; use --browser to enable)",
"extra": "Extra raw args appended to streamlit command (optional)",
"python": "Python executable to use (default: python3 or $PYTHON)",
}
)
def run(
c,
app: str = DEFAULT_APP,
port: int = 8501,
server_headless: bool = False,
browser: bool = False,
extra: str | None = None,
python: str | None = None,
):
"""
Launch Streamlit app:
streamlit run app.py
"""
py = python or os.environ.get("PYTHON") or "python3"
# Use `python -m streamlit` to avoid PATH issues where `streamlit` command isn't found.
cmd_parts = [
_q(py),
"-m",
"streamlit",
"run",
_q(app),
"--server.port",
_q(str(port)),
]
if server_headless:
cmd_parts += ["--server.headless", "true"]
# Streamlit default is to open browser; many people want the opposite in dev shells.
# We treat --browser as an opt-in to open.
if not browser:
cmd_parts += ["--server.headless", "true"] if server_headless else []
# If you want to *force* no browser open locally, Streamlit doesn't have a perfect flag,
# but headless=true is the common approach.
if extra:
cmd_parts.append(extra)
cmd = " ".join(cmd_parts)
c.run(cmd, pty=True)
@task
def version(c, script: str = DEFAULT_SCRIPT, python: str | None = None):
"""Show msg2json.py --help (quick sanity check)."""
py = python or os.environ.get("PYTHON") or "python3"
c.run(f"{_q(py)} {_q(script)} --help", pty=True)
使用方法¶
準備¶
msg/ 以下に.msgファイルを置く.
コマンド¶
$ invoke msg2json
$ invoke run