#!/usr/bin/env python3 """設定に応じてClaude CodeまたはCodex CLIを実行する。""" from __future__ import annotations import argparse import shutil import subprocess import sys from dataclasses import dataclass from pathlib import Path from common import CONFIG COMMON_ROOT = Path(__file__).resolve().parents[1] INSTRUCTIONS_FILE = COMMON_ROOT / "CLAUDE.md" AI_PROMPT = """ 以下の共通指示に従い、TASK=process_new_papersを 追加確認なしで最後まで実行してください。 --- 共通指示開始 --- {common_instructions} --- 共通指示終了 --- tmp/text/には、未登録PDFから抽出したテキストがあります。 対象分野: {domain_name} 対象分野の説明: {domain_description} categoryは次のいずれかを使用してください。 {categories} 日本語要約は{summary_length_ja}を目安とし、 次の観点を優先してください。 {summary_focus} relevanceは次の基準で判定してください。 {relevance_criteria} Priorityは次の対応にしてください。 - A: relevance=5 - B: relevance=4 - C: relevance=3 - D: relevance=0〜2 行う処理は次だけです。 - tmp/text/にあるテキストファイルを解析する - 各テキスト先頭のPDF_PATHをJSONのpdf_pathに使う - DOI、書誌情報、要約、キーワード、分類を抽出する - DOIがある場合はlanding_urlを生成する - 必要最小限のWeb検索で公開PDFを確認する - 各論文についてtmp/paper-*.jsonを作成する - 作成件数と結果を日本語で報告する 原則としてPDFは直接読まないでください。 次の場合のみPDF_PATHに記載された元PDFを参照できます。 - PDF_TEXT_EMPTYがyes - TEXT_INSUFFICIENTがyes - TEXT_LENGTHが{min_text_length}未満 - テキストが文字化けしている - タイトル、著者、発行年、要約を確認できない 元PDFを参照する場合も、PDF_PATHに記載された1ファイルだけを読み、 他のPDFやディレクトリを探索しないでください。 確認できない内容、DOI、pdf_urlは推測しないでください。 papers.dbの直接更新、DB検証、Org出力は実行しないでください。 最大{max_papers}件だけ処理してください。 """.strip() @dataclass(frozen=True) class RuntimeOptions: backend: str max_papers: int def positive_int(value: str) -> int: number = int(value) if number < 1: raise argparse.ArgumentTypeError( "1以上の整数を指定してください" ) return number def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Claude CodeまたはCodex CLIを実行する" ) parser.add_argument( "--backend", choices=("claude", "codex"), default=None, ) parser.add_argument( "--max-papers", type=positive_int, default=None, metavar="N", ) return parser.parse_args() def resolve_options(args: argparse.Namespace) -> RuntimeOptions: return RuntimeOptions( backend=args.backend or CONFIG.ai.backend, max_papers=( args.max_papers if args.max_papers is not None else CONFIG.processing.max_papers ), ) def load_common_instructions() -> str: if not INSTRUCTIONS_FILE.is_file(): raise FileNotFoundError( f"共通CLAUDE.mdがありません: {INSTRUCTIONS_FILE}" ) return INSTRUCTIONS_FILE.read_text(encoding="utf-8").strip() def build_prompt(max_papers: int) -> str: domain = CONFIG.domain return AI_PROMPT.format( common_instructions=load_common_instructions(), domain_name=domain.name, domain_description=domain.description, categories=domain.categories_prompt(), summary_length_ja=domain.summary_length_ja, summary_focus=domain.summary_focus_prompt(), relevance_criteria=domain.relevance_prompt(), min_text_length=CONFIG.processing.min_text_length, max_papers=max_papers, ) def build_claude_command() -> list[str]: command = [ "claude", "--print", "--add-dir", str(CONFIG.path.text_dir), "--permission-mode", CONFIG.ai.claude_permission_mode, "--allowedTools", CONFIG.ai.claude_allowed_tools_argument, ] for path in CONFIG.scan_paths: command.extend(["--add-dir", str(path)]) return command def build_codex_command(max_papers: int) -> list[str]: command = [ "codex", "exec", "--cd", str(CONFIG.path.project_root), "--sandbox", CONFIG.ai.codex_sandbox, ] if CONFIG.ai.codex_model: command.extend(["--model", CONFIG.ai.codex_model]) if CONFIG.ai.codex_full_auto: command.append("--full-auto") command.append(build_prompt(max_papers)) return command def build_command(options: RuntimeOptions) -> list[str]: if options.backend == "claude": return build_claude_command() if options.backend == "codex": return build_codex_command(options.max_papers) raise ValueError(f"未対応のAIバックエンドです: {options.backend}") def require_backend_command(backend: str) -> None: executable = {"claude": "claude", "codex": "codex"}.get(backend) if executable is None: raise ValueError(f"未対応のAIバックエンドです: {backend}") if shutil.which(executable) is None: raise RuntimeError( f"必要なコマンドが見つかりません: {executable}" ) def main() -> int: args = parse_args() options = resolve_options(args) try: CONFIG.validate() require_backend_command(options.backend) command = build_command(options) prompt = build_prompt(options.max_papers) completed = subprocess.run( command, cwd=CONFIG.path.project_root, input=(prompt if options.backend == "claude" else None), text=True, check=False, ) return completed.returncode except Exception as exc: print(f"run_ai.py: {exc}", file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main())