221 lines
6.2 KiB
Python
Executable File
221 lines
6.2 KiB
Python
Executable File
#!/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())
|