diff --git a/docs/superpowers/plans/2026-06-09-law-explainer-seo.md b/docs/superpowers/plans/2026-06-09-law-explainer-seo.md new file mode 100644 index 0000000..e66a3d9 --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-law-explainer-seo.md @@ -0,0 +1,849 @@ +# `/law`「最近の改正をわかりやすく」解説 実装プラン(B案 Phase1) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `/law/{lawId}` ページに、最近の改正を平易に解説するAI生成セクションを追加し、検索露出をクリックに変える(Phase1=4法令)。 + +**Architecture:** 既存 `law_summary.py` と同じ疎結合パターン。新規 `scripts/explainer.py` がClaudeで `explainer` を生成しtimeline JSONに格納。Pythonの選別/正規化/検証は純関数化してTDD。フロントは `LawExplainerSection` コンポーネントで描画。`explainer?` はoptionalで後方互換。 + +**Tech Stack:** Python 3.13 (uv, anthropic), pytest, Next.js App Router, TypeScript, Tailwind。 + +設計spec: `docs/superpowers/specs/2026-06-09-law-explainer-seo-design.md` + +--- + +## File Structure + +- `frontend/lib/types.ts`(変更)— `ExplainerChange`/`ExplainerFaq`/`LawExplainer` 追加、`LawTimeline.explainer?`。 +- `scripts/explainer.py`(新規)— 選別`select_changes`・正規化`normalize_explainer`・検証`validate_explainer`の純関数+Claude生成+I/O。 +- `tests/test_explainer.py`(新規)— 純関数のpytest。 +- `frontend/components/law-explainer.tsx`(新規)— 描画コンポーネント。 +- `frontend/app/law/[lawId]/page.tsx`(変更)— セクション描画+`generateMetadata`のdescription優先順位。 +- データ: `data/timelines/{4法令}.json` と `frontend/public/data/timelines/{4法令}.json`(生成物)。 + +Phase1対象 law_id: `416AC0000000123`(不動産登記法) / `129AC0000000089`(民法) / `322AC0000000049`(労働基準法) / `335AC0000000105`(道路交通法)。 + +--- + +## Task 1: 型定義の追加 + +**Files:** +- Modify: `frontend/lib/types.ts`(`LawTimeline` interface 付近、現状106-116行) + +- [ ] **Step 1: 型を追加** + +`frontend/lib/types.ts` の `LawSummary` interface(93-97行)の直後に追加: + +```ts +export interface ExplainerChange { + year: string; + title: string; + what: string; + why?: string; + impact?: string; + grounded: boolean; +} + +export interface ExplainerFaq { + q: string; + a: string; +} + +export interface LawExplainer { + intro: string; + recent_changes: ExplainerChange[]; + faq: ExplainerFaq[]; +} +``` + +`LawTimeline` interface に1行追加: + +```ts +export interface LawTimeline { + law_id: string; + law_title: string; + law_num: string; + promulgation_date: string; + revision_count: number; + timeline: TimelineEntry[]; + summary?: LawSummary; + category?: string; + contributors?: Contributor[]; + explainer?: LawExplainer; +} +``` + +- [ ] **Step 2: 型チェック** + +Run: `cd frontend && npx tsc --noEmit` +Expected: PASS(エラーなし。`explainer`はoptionalなので既存コード影響なし) + +- [ ] **Step 3: Commit** + +```bash +git add frontend/lib/types.ts +git commit -m "feat: add LawExplainer types" +``` + +--- + +## Task 2: pytest 導入と選別関数 `select_changes`(TDD) + +**Files:** +- Modify: `pyproject.toml`(dev依存) +- Create: `scripts/explainer.py` +- Create: `tests/test_explainer.py` + +- [ ] **Step 1: pytest を dev 依存に追加** + +Run: `uv add --dev pytest` +Expected: `pyproject.toml` に `[dependency-groups]` または `[tool.uv]` でpytestが追加され、`uv.lock`更新。 + +- [ ] **Step 2: 失敗するテストを書く** + +`tests/test_explainer.py` を作成: + +```python +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "scripts")) + +from explainer import select_changes + + +def test_select_dedupes_by_title_prefers_diff_id(): + timeline = [ + {"enforcement_date": "2024-04-01", "amendment_law_title": "民法等の一部を改正する法律", + "diff_id": "129_2024"}, + {"enforcement_date": "2026-04-01", "amendment_law_title": "民法等の一部を改正する法律", + "diff_id": None}, + ] + result = select_changes(timeline) + # same title collapses to one, the diff_id-bearing entry wins (grounded) + assert len(result) == 1 + assert result[0]["grounded"] is True + assert result[0]["diff_id"] == "129_2024" + assert result[0]["year"] == "2024" + + +def test_select_grounded_first_then_date_desc(): + timeline = [ + {"enforcement_date": "2020-01-01", "amendment_law_title": "A法", "diff_id": "d1"}, + {"enforcement_date": "2025-01-01", "amendment_law_title": "B法", "diff_id": None}, + {"enforcement_date": "2023-01-01", "amendment_law_title": "C法", "diff_id": None}, + ] + result = select_changes(timeline) + # grounded (A) first, then ungrounded by date desc (B 2025, C 2023) + assert [c["year"] for c in result] == ["2020", "2025", "2023"] + assert result[0]["grounded"] is True + assert result[1]["grounded"] is False + + +def test_select_caps_at_four_and_skips_empty_title(): + timeline = [{"enforcement_date": f"20{10+i}-01-01", + "amendment_law_title": f"法{i}", "diff_id": None} for i in range(6)] + timeline.append({"enforcement_date": "2030-01-01", "amendment_law_title": "", "diff_id": None}) + result = select_changes(timeline) + assert len(result) == 4 + assert all(c["year"] for c in result) +``` + +- [ ] **Step 3: テストが失敗することを確認** + +Run: `uv run pytest tests/test_explainer.py -v` +Expected: FAIL(`ModuleNotFoundError` か `ImportError: cannot import name 'select_changes'`) + +- [ ] **Step 4: `scripts/explainer.py` に最小実装** + +`scripts/explainer.py` を作成(まず定数と `select_changes` のみ): + +```python +"""Generate a plain-language 'recent amendments' explainer for a law using Claude AI. + +Usage: + python scripts/explainer.py + +Example: + python scripts/explainer.py 416AC0000000123 +""" + +import sys +import json +import os +from pathlib import Path + +MODEL = "claude-sonnet-4-6" +ROOT = Path(__file__).parent.parent +DATA_DIR = ROOT / "data" +FRONTEND_DIR = ROOT / "frontend" / "public" / "data" +MAX_CHANGES = 4 + + +def select_changes(timeline: list[dict]) -> list[dict]: + """Deterministically select up to MAX_CHANGES meaningful amendments. + + - dedupe by amendment_law_title (keep the diff_id-bearing entry, else latest date) + - grounded = bool(diff_id) + - order: grounded first, then enforcement_date descending + """ + by_title: dict[str, dict] = {} + for entry in timeline: + title = entry.get("amendment_law_title", "") + if not title: + continue + cand = { + "source_title": title, + "enforcement_date": entry.get("enforcement_date", "") or "", + "diff_id": entry.get("diff_id"), + } + cur = by_title.get(title) + if cur is None: + by_title[title] = cand + continue + cur_has, cand_has = bool(cur["diff_id"]), bool(cand["diff_id"]) + if (cand_has and not cur_has) or ( + cand_has == cur_has and cand["enforcement_date"] > cur["enforcement_date"] + ): + by_title[title] = cand + + changes = [ + { + "year": c["enforcement_date"][:4] if c["enforcement_date"] else "", + "source_title": c["source_title"], + "enforcement_date": c["enforcement_date"], + "diff_id": c["diff_id"], + "grounded": bool(c["diff_id"]), + } + for c in by_title.values() + ] + changes.sort(key=lambda c: (c["grounded"], c["enforcement_date"]), reverse=True) + return changes[:MAX_CHANGES] +``` + +- [ ] **Step 5: テストが通ることを確認** + +Run: `uv run pytest tests/test_explainer.py -v` +Expected: PASS(3 tests) + +- [ ] **Step 6: Commit** + +```bash +git add pyproject.toml uv.lock scripts/explainer.py tests/test_explainer.py +git commit -m "feat: add select_changes with pytest (explainer Phase1)" +``` + +--- + +## Task 3: 検証関数 `validate_explainer`(TDD) + +**Files:** +- Modify: `scripts/explainer.py` +- Modify: `tests/test_explainer.py` + +- [ ] **Step 1: 失敗するテストを追加** + +`tests/test_explainer.py` の import 行に `validate_explainer` を追加し、末尾にテストを追加: + +```python +from explainer import select_changes, validate_explainer + +VALID_YEARS = {"2024", "2026"} + + +def _ok_explainer(): + return { + "intro": "不動産登記法は近年、相続登記の義務化など大きな改正が続いています。マイホームや相続の手続きに直接関わる重要な変更です。", + "recent_changes": [ + {"year": "2024", "title": "相続登記の義務化", "what": "相続を知った日から3年以内の登記が必須になりました。", + "why": "所有者不明土地の増加が社会問題化したためです。", "impact": "相続人は期限内の手続きが必要です。", "grounded": True}, + {"year": "2026", "title": "登記手続のデジタル化", "what": "オンラインでの手続きが拡充されました。", "grounded": False}, + ], + "faq": [{"q": "相続登記はいつから義務?", "a": "2024年4月1日から施行されています。"}], + } + + +def test_validate_passes_on_good_explainer(): + assert validate_explainer(_ok_explainer(), VALID_YEARS) == [] + + +def test_validate_flags_ungrounded_with_why(): + bad = _ok_explainer() + bad["recent_changes"][1]["why"] = "推測の背景" + errors = validate_explainer(bad, VALID_YEARS) + assert any("ungrounded" in e for e in errors) + + +def test_validate_flags_year_not_in_timeline(): + bad = _ok_explainer() + bad["recent_changes"][0]["year"] = "1999" + errors = validate_explainer(bad, VALID_YEARS) + assert any("year" in e for e in errors) + + +def test_validate_flags_missing_key(): + bad = _ok_explainer() + del bad["intro"] + errors = validate_explainer(bad, VALID_YEARS) + assert any("intro" in e for e in errors) +``` + +- [ ] **Step 2: テストが失敗することを確認** + +Run: `uv run pytest tests/test_explainer.py -v` +Expected: FAIL(`ImportError: cannot import name 'validate_explainer'`) + +- [ ] **Step 3: `validate_explainer` を実装** + +`scripts/explainer.py` の `select_changes` の下に追加: + +```python +def validate_explainer(explainer: dict, valid_years: set[str]) -> list[str]: + """Return a list of human-readable validation errors (empty = valid).""" + errors: list[str] = [] + for key in ("intro", "recent_changes", "faq"): + if key not in explainer: + errors.append(f"missing key: {key}") + if errors: + return errors + + intro = explainer["intro"] + if not isinstance(intro, str) or not (40 <= len(intro) <= 200): + errors.append("intro length out of range (40-200)") + + rc = explainer["recent_changes"] + if not isinstance(rc, list) or not (1 <= len(rc) <= MAX_CHANGES): + errors.append(f"recent_changes count out of range (1-{MAX_CHANGES})") + else: + for i, c in enumerate(rc): + for k in ("year", "title", "what", "grounded"): + if k not in c: + errors.append(f"recent_changes[{i}] missing {k}") + if c.get("year") not in valid_years: + errors.append(f"recent_changes[{i}] year {c.get('year')} not in timeline") + what = c.get("what", "") + if not isinstance(what, str) or not (0 < len(what) <= 120): + errors.append(f"recent_changes[{i}] what length out of range") + if not c.get("grounded") and (c.get("why") or c.get("impact")): + errors.append(f"recent_changes[{i}] ungrounded must not have why/impact") + + faq = explainer["faq"] + if not isinstance(faq, list): + errors.append("faq must be a list") + else: + for i, f in enumerate(faq): + if not f.get("q") or not f.get("a"): + errors.append(f"faq[{i}] missing q/a") + return errors +``` + +- [ ] **Step 4: テストが通ることを確認** + +Run: `uv run pytest tests/test_explainer.py -v` +Expected: PASS(7 tests) + +- [ ] **Step 5: Commit** + +```bash +git add scripts/explainer.py tests/test_explainer.py +git commit -m "feat: add validate_explainer (explainer Phase1)" +``` + +--- + +## Task 4: 正規化関数 `normalize_explainer`(TDD) + +**Files:** +- Modify: `scripts/explainer.py` +- Modify: `tests/test_explainer.py` + +LLM出力に決定的な `year`/`grounded` を再付与し、`faq`欠落→`[]`、ungroundedの`why`/`impact`除去を行う。 + +- [ ] **Step 1: 失敗するテストを追加** + +import 行を `from explainer import select_changes, validate_explainer, normalize_explainer` に更新し、末尾に追加: + +```python +def test_normalize_reattaches_year_grounded_and_strips_ungrounded(): + selected = [ + {"year": "2024", "source_title": "民法等の一部を改正する法律", "grounded": True}, + {"year": "2026", "source_title": "整理法", "grounded": False}, + ] + raw = { + "intro": " 導入文 ", + "recent_changes": [ + {"title": "相続登記の義務化", "what": "3年以内に登記。", "why": "所有者不明土地対策。", "impact": "過料あり。"}, + {"title": "技術的整理", "what": "条ずれの整理。", "why": "LLMが勝手に書いた背景", "impact": "影響も勝手に"}, + ], + # faq missing entirely + } + result = normalize_explainer(raw, selected) + assert result["intro"] == "導入文" + assert result["recent_changes"][0]["grounded"] is True + assert result["recent_changes"][0]["why"] == "所有者不明土地対策。" + # ungrounded: why/impact stripped + assert result["recent_changes"][1]["grounded"] is False + assert "why" not in result["recent_changes"][1] + assert "impact" not in result["recent_changes"][1] + # faq normalized to [] + assert result["faq"] == [] + + +def test_normalize_filters_incomplete_faq(): + selected = [{"year": "2024", "source_title": "X", "grounded": True}] + raw = {"intro": "x", "recent_changes": [{"title": "t", "what": "w", "why": "y", "impact": "i"}], + "faq": [{"q": "問", "a": "答"}, {"q": "", "a": "答だけ"}, {"q": "問だけ"}]} + result = normalize_explainer(raw, selected) + assert result["faq"] == [{"q": "問", "a": "答"}] +``` + +- [ ] **Step 2: テストが失敗することを確認** + +Run: `uv run pytest tests/test_explainer.py -v` +Expected: FAIL(`ImportError: cannot import name 'normalize_explainer'`) + +- [ ] **Step 3: `normalize_explainer` を実装** + +`scripts/explainer.py` の `validate_explainer` の下に追加: + +```python +def normalize_explainer(raw: dict, selected: list[dict]) -> dict: + """Re-attach deterministic year/grounded from `selected`, keep LLM prose, + strip why/impact from ungrounded changes, normalize faq to a clean list.""" + changes = [] + for sel, item in zip(selected, raw.get("recent_changes", []) or []): + change = { + "year": sel["year"], + "title": (item.get("title") or sel["source_title"]).strip(), + "what": (item.get("what") or "").strip(), + "grounded": sel["grounded"], + } + if sel["grounded"]: + why = (item.get("why") or "").strip() + impact = (item.get("impact") or "").strip() + if why: + change["why"] = why + if impact: + change["impact"] = impact + changes.append(change) + + faq = [ + {"q": f.get("q", "").strip(), "a": f.get("a", "").strip()} + for f in (raw.get("faq") or []) + if f.get("q") and f.get("a") + ] + return {"intro": (raw.get("intro") or "").strip(), "recent_changes": changes, "faq": faq} +``` + +- [ ] **Step 4: テストが通ることを確認** + +Run: `uv run pytest tests/test_explainer.py -v` +Expected: PASS(9 tests) + +- [ ] **Step 5: Commit** + +```bash +git add scripts/explainer.py tests/test_explainer.py +git commit -m "feat: add normalize_explainer (explainer Phase1)" +``` + +--- + +## Task 5: 生成のグルー(Claude呼び出し+I/O) + +**Files:** +- Modify: `scripts/explainer.py` + +純関数はテスト済み。ここでClaude呼び出し・pr_summary読み込み・プロンプト構築・main を足す。API依存のため自動テストは無し(Task7で実行検証)。 + +- [ ] **Step 1: import と補助I/Oを追加** + +`scripts/explainer.py` の先頭 import 群に `import anthropic` を追加(`import os` の下)。 + +`normalize_explainer` の下に、`law_summary.py` と同じ `load_env` と pr_summary 読み込みを追加: + +```python +def load_env(): + env_path = ROOT / ".env" + if env_path.exists(): + for line in env_path.read_text().splitlines(): + line = line.strip() + if line and not line.startswith("#") and "=" in line: + key, _, value = line.partition("=") + os.environ.setdefault(key.strip(), value.strip()) + + +def load_pr_summary(diff_id: str) -> dict | None: + """Load pr_summary for a diff_id from the frontend data dir or data/diffs.""" + for base in (FRONTEND_DIR, DATA_DIR / "diffs"): + path = base / f"{diff_id}.json" + if path.exists(): + data = json.loads(path.read_text()) + return data.get("pr_summary") + return None +``` + +- [ ] **Step 2: プロンプト構築と生成を追加** + +```python +def build_prompt(law_title, law_num, category, summary_desc, changes): + lines = [] + for i, c in enumerate(changes): + block = [f"[改正{i + 1}] 施行年: {c['year']} / 改正法: {c['source_title']}"] + if c["grounded"]: + pr = c.get("pr_summary") or {} + block.append(" 根拠あり(grounded)。以下の差分要約に基づき、what/why/impactを書いてよい:") + block.append(f" タイトル: {pr.get('title', '')}") + block.append(f" 概要: {pr.get('summary', '')}") + block.append(f" 背景: {pr.get('background', '')}") + block.append(f" 影響: {pr.get('impact', '')}") + else: + block.append(" 根拠なし(ungrounded)。改正法名と施行年のみ確認済み。" + "whatは改正法名から言える範囲に留め、why/impactは書かないこと。") + lines.append("\n".join(block)) + changes_text = "\n\n".join(lines) + + return f"""あなたは日本の法律をわかりやすく解説する専門家です。以下の法律の「最近の改正」を、一般市民向けにJSON形式で解説してください。 + +法令名: {law_title} +法令番号: {law_num} +分類: {category} +法律の概要: {summary_desc} + +最近の主要改正(この{len(changes)}件についてのみ書く。順番・件数を変えない): +{changes_text} + +出力JSON形式: +{{ + "intro": "この法律で近年どんな改正が続いているかの導入(80-150字、専門用語を避ける)", + "recent_changes": [ + {{"title": "改正の通称(例: 相続登記の義務化)", "what": "何が変わったか(40-100字)", "why": "なぜ変わったか(grounded時のみ・40-100字)", "impact": "私たちへの影響(grounded時のみ・40-100字)"}} + ], + "faq": [ + {{"q": "よくある質問(例: いつから?)", "a": "回答(40-120字)"}} + ] +}} + +ルール: +- recent_changes は入力の改正と同じ順番・同じ件数で出力する。 +- ungrounded の改正では why/impact を出力しない(キーごと省略)。 +- 提供情報に無い事実(数値・期限・因果)を断定しない。不確実なら書かない。 +- 見出しや本文に「{law_title}」「改正」「わかりやすく」が自然に含まれるとよい。 +- faq は0-3件。確実に答えられるものだけ。 +- JSONのみを出力する。""" + + +def generate_explainer(law_title, law_num, category, summary_desc, changes) -> dict: + client = anthropic.Anthropic() + prompt = build_prompt(law_title, law_num, category, summary_desc, changes) + response = client.messages.create( + model=MODEL, + max_tokens=2000, + messages=[{"role": "user", "content": prompt}], + ) + text = response.content[0].text.strip() + if text.startswith("```"): + text = text.split("\n", 1)[1].rsplit("```", 1)[0].strip() + return json.loads(text) +``` + +- [ ] **Step 3: main を追加** + +```python +def main(): + if len(sys.argv) < 2: + print(__doc__) + sys.exit(1) + + load_env() + law_id = sys.argv[1] + + timeline_path = DATA_DIR / "timelines" / f"{law_id}.json" + if not timeline_path.exists(): + print(f"Error: Run timeline.py first for {law_id}") + sys.exit(1) + timeline = json.loads(timeline_path.read_text()) + + changes = select_changes(timeline["timeline"]) + if not changes: + print(f"Error: no usable amendments for {law_id}") + sys.exit(1) + for c in changes: + if c["grounded"] and c["diff_id"]: + c["pr_summary"] = load_pr_summary(c["diff_id"]) + if not c["pr_summary"]: + # diff file missing → downgrade to ungrounded for safety + c["grounded"] = False + + print(f"Generating explainer for {timeline['law_title']} ({len(changes)} changes)...") + raw = generate_explainer( + timeline["law_title"], + timeline["law_num"], + timeline.get("category", ""), + (timeline.get("summary") or {}).get("description", ""), + changes, + ) + explainer = normalize_explainer(raw, changes) + + valid_years = {e.get("enforcement_date", "")[:4] for e in timeline["timeline"]} + errors = validate_explainer(explainer, valid_years) + if errors: + print("VALIDATION FAILED:") + for e in errors: + print(f" - {e}") + print("\nGenerated (not saved):") + print(json.dumps(explainer, ensure_ascii=False, indent=2)) + sys.exit(2) + + timeline["explainer"] = explainer + timeline_path.write_text(json.dumps(timeline, ensure_ascii=False, indent=2)) + frontend_path = FRONTEND_DIR / "timelines" / f"{law_id}.json" + if frontend_path.exists(): + frontend_path.write_text(json.dumps(timeline, ensure_ascii=False, indent=2)) + + print(f" intro: {explainer['intro'][:60]}...") + print(f" recent_changes: {len(explainer['recent_changes'])}, faq: {len(explainer['faq'])}") + print(f" Saved: {timeline_path}") + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 4: import が壊れていないことを確認(純関数テスト再実行)** + +Run: `uv run pytest tests/test_explainer.py -v` +Expected: PASS(9 tests。`import anthropic` 追加後もテストは純関数のみ参照) + +- [ ] **Step 5: Commit** + +```bash +git add scripts/explainer.py +git commit -m "feat: add Claude generation + IO glue to explainer.py" +``` + +--- + +## Task 6: フロント描画コンポーネントとページ結線 + +**Files:** +- Create: `frontend/components/law-explainer.tsx` +- Modify: `frontend/app/law/[lawId]/page.tsx` + +- [ ] **Step 1: コンポーネントを作成** + +`frontend/components/law-explainer.tsx`: + +```tsx +import { Icon } from "@/components/icon"; +import type { LawExplainer } from "@/lib/types"; + +export function LawExplainerSection({ + lawTitle, + explainer, +}: { + lawTitle: string; + explainer: LawExplainer; +}) { + return ( +
+
+ +

+ {lawTitle}の最近の改正をわかりやすく +

+ AI要約 +
+
+

{explainer.intro}

+ +
+ {explainer.recent_changes.map((c, i) => ( +
+
+ + {c.year} + +

{c.title}

+
+
+
+
何が変わった?
+
{c.what}
+
+ {c.why && ( +
+
なぜ変わった?
+
{c.why}
+
+ )} + {c.impact && ( +
+
私たちへの影響
+
{c.impact}
+
+ )} +
+
+ ))} +
+ + {explainer.faq.length > 0 && ( +
+

よくある質問

+
+ {explainer.faq.map((f, i) => ( +
+

Q. {f.q}

+

+ A. {f.a} +

+
+ ))} +
+
+ )} +
+
+ ); +} +``` + +- [ ] **Step 2: ページに結線(import・metadata・描画)** + +`frontend/app/law/[lawId]/page.tsx` を3箇所変更: + +(a) import 追加(`BreadcrumbJsonLd` import の下、8行目付近): + +```tsx +import { LawExplainerSection } from "@/components/law-explainer"; +``` + +(b) `generateMetadata` 内の `desc` 定義(現状21行目)を、`explainer.intro` 優先に変更: + +```tsx + const desc = + data.explainer?.intro || + data.summary?.description || + `${data.law_title}の改正履歴を時系列で一覧。全${data.revision_count}回の改正について、いつ・どの条文が・どう変わったかをわかりやすく確認できます。`; +``` + +(c) 「この法律について」の summary カード(`{data.summary && ( ... )}` ブロック、現状102-150行)の**直後**に追加: + +```tsx + {data.explainer && ( + + )} +``` + +- [ ] **Step 3: 型チェックとlint** + +Run: `cd frontend && npx tsc --noEmit && npm run lint` +Expected: PASS(エラー・警告なし) + +- [ ] **Step 4: Commit** + +```bash +git add frontend/components/law-explainer.tsx frontend/app/law/[lawId]/page.tsx +git commit -m "feat: render LawExplainerSection on /law page" +``` + +--- + +## Task 7: 4法令の生成・事実性レビュー・ビルド検証 + +**Files:** +- データ生成: `data/timelines/{4法令}.json`, `frontend/public/data/timelines/{4法令}.json` + +- [ ] **Step 1: 4法令で生成を実行** + +Run: +```bash +uv run python scripts/explainer.py 416AC0000000123 +uv run python scripts/explainer.py 129AC0000000089 +uv run python scripts/explainer.py 322AC0000000049 +uv run python scripts/explainer.py 335AC0000000105 +``` +Expected: 各コマンドが `Saved:` を表示して終了(VALIDATION FAILEDが出た場合は再実行、または当該記述を要確認)。 + +- [ ] **Step 2: 事実性レビュー(手動・必須ゲート)** + +各JSONの `explainer` を開き、spec「事実性レビューゲート」に従い突合: +- 各 `recent_changes` の `year`/`title` が timeline の施行年・改正法名と整合。 +- `grounded:true` の `why`/`impact` が当該 diff の `pr_summary` の範囲内。 +- `faq` の回答に未確認の数値・期限の断定が無い。 +- 問題があれば該当記述を手修正、または再生成。 + +Run(確認補助): +```bash +for id in 416AC0000000123 129AC0000000089 322AC0000000049 335AC0000000105; do + echo "=== $id ==="; python3 -c "import json;e=json.load(open(f'frontend/public/data/timelines/$id.json'))['explainer'];print(json.dumps(e,ensure_ascii=False,indent=2))" +done +``` + +- [ ] **Step 3: ビルド** + +Run: `cd frontend && npm run build` +Expected: PASS(エラーなし) + +- [ ] **Step 4: ビルド出力に見出し・本文が含まれることを確認** + +Run: +```bash +cd frontend +for id in 416AC0000000123 129AC0000000089 322AC0000000049 335AC0000000105; do + echo "=== $id ==="; grep -o '最近の改正をわかりやすく' "out/law/$id.html" | head -1 || echo "MISSING" +done +``` +Expected: 各法令で `最近の改正をわかりやすく` がヒット。 + +- [ ] **Step 5: 後方互換の確認(explainer未生成の法令)** + +Run: +```bash +cd frontend +# explainerを持たない任意の法令ページがビルドされ、セクションが無いこと +grep -L '最近の改正をわかりやすく' out/law/140AC0000000045.html && echo "OK: no section on non-target law" +``` +Expected: `OK: no section on non-target law`(対象外法令にはセクションが無い) + +- [ ] **Step 6: Commit** + +```bash +git add data/timelines frontend/public/data/timelines +git commit -m "data: generate explainer for 4 Phase1 laws" +``` + +--- + +## Task 8: 本番反映 + +- [ ] **Step 1: push(Vercel自動デプロイ)** + +Run: `git push origin main` +Expected: GitHubへ反映。Vercelが本番デプロイを開始。 + +- [ ] **Step 2: デプロイ完了と本番表示を確認** + +Vercel MCP `list_deployments`/`get_deployment` で最新コミットの本番デプロイが READY になることを確認後: + +Run: +```bash +curl -s https://lexdiff.com/law/416AC0000000123 | grep -o '最近の改正をわかりやすく' | head -1 +``` +Expected: ヒットする(本番反映済み)。 + +- [ ] **Step 3: 観測のメモ** + +spec「成功条件・観測(Phase1)」に従い、2-3週間後にGSC/GA4で4法令の `/law` のクリック・CTR・対象クエリ順位を確認する旨をTODO/メモに残す。 + +--- + +## Self-Review チェック結果 + +- **Spec coverage**: データ構造(Task1)・集約と根拠付け(Task2,5)・検証(Task3)・正規化/faq正規化(Task4)・描画(Task6)・meta description(Task6 Step2b)・事実性レビューと自動検証(Task3,7)・成功条件の観測(Task8)・FAQ JSON-LDはスコープ外(実装せず=spec整合) — 各specセクションに対応タスクあり。 +- **Placeholder scan**: TBD/TODO無し。各コード手順に実コードを記載。 +- **Type consistency**: `select_changes`→`normalize_explainer`の`selected`要素キー(`year`/`source_title`/`grounded`)、`LawExplainer`/`ExplainerChange`(`why`/`impact` optional, `grounded` required)はTask1の型・Task6の描画(`c.why`/`c.impact`のoptional参照)と一致。`validate_explainer(explainer, valid_years)`のシグネチャはTask5 main呼び出しと一致。 diff --git a/docs/superpowers/specs/2026-06-09-law-explainer-seo-design.md b/docs/superpowers/specs/2026-06-09-law-explainer-seo-design.md new file mode 100644 index 0000000..ed098c1 --- /dev/null +++ b/docs/superpowers/specs/2026-06-09-law-explainer-seo-design.md @@ -0,0 +1,172 @@ +# 設計: `/law` ページへの「最近の改正をわかりやすく」解説の追加(SEO強化 B案) + +- 日付: 2026-06-09 +- 関連: GSC/GA4分析(imp急増・CTRほぼ0、`/law`改正履歴ページが勝ち型)、canonical修正(commit c83dd62)の後続施策 + +## 背景・目的 + +GSC分析の結論: +- 表示回数(imp)は4月~25/日 → 6月~60-93/日へ急増しているが、クリックはほぼ0(CTR最適化が最大レバレッジ)。 +- `/law/{lawId}`(改正履歴)ページが唯一クリックを獲得(「不動産登記法改正 履歴」で順位3.5・CTR15%)。`/diff/`より検索意図に合致。 +- 「○○法改正 わかりやすく」「○○法 改正 履歴」が有望クエリ。「不動産登記法 改正 わかりやすく」は順位45=未対応。 + +**目的**:`/law`ページに「最近の改正をわかりやすく」長文解説を追加し、上がってきた露出を実クリックに変える。検索意図=「最近この法律はどう変わったか」を平易に説明する。 + +## スコープ + +- **Phase 1(本spec)**:実績上位4法令のみ生成・検証する。 + - 不動産登記法 `416AC0000000123`(順位3.5・クリック実績) + - 民法 `129AC0000000089`(858impの最大露出源) + - 労働基準法 `322AC0000000049`(改正履歴で露出開始) + - 道路交通法 `335AC0000000105`(wwwで露出) +- **対象外(将来)**:残り法令への横展開はPhase1の効果をGSCで確認後に判断。 + +## アーキテクチャ方針 + +既存の `law_summary.py`(Claudeで`summary`を生成→timeline JSONに格納→`/law`ページで描画)と同一の疎結合パターンを踏襲する。データ生成(Python/Claude)とレンダリング(Next.js)は分離。 + +``` +timeline.py → data/timelines/{law_id}.json + → explainer.py (Claude, 新規) → 同JSONに explainer フィールド追記 + → frontend/public/data/timelines/{law_id}.json + → /law/[lawId]/page.tsx が描画 +``` + +## データ構造 + +`lib/types.ts` の `LawTimeline` に optional フィールドを追加(**未生成の法令は従来表示のまま=後方互換**)。 + +```ts +export interface ExplainerChange { + year: string; // 例: "2024" + title: string; // 改正の通称。例: "相続登記の義務化" + what: string; // 何が変わったか(平易な1-3文) + why?: string; // なぜ変わったか(背景)。根拠(diff/pr_summary)がある場合のみ生成。無ければ省略 + impact?: string; // 私たちへの影響。根拠がある場合のみ生成。無ければ省略 + grounded: boolean; // 条文差分(pr_summary)に基づくか。falseは施行日・改正法名のみ確認済みの軽量エントリ +} + +export interface ExplainerFaq { + q: string; + a: string; +} + +export interface LawExplainer { + intro: string; // 導入2-3文 + recent_changes: ExplainerChange[]; // 最近の主要改正 2-4件(改正法/トピック単位に集約) + faq: ExplainerFaq[]; // よくある質問 0-4件(欠落/null時は[]に正規化) +} + +export interface LawTimeline { + // ...既存フィールド + explainer?: LawExplainer; +} +``` + +JSON例: +```jsonc +"explainer": { + "intro": "不動産登記法は近年、相続登記の義務化など大きな改正が続いています。…", + "recent_changes": [ + { + "year": "2024", + "title": "相続登記の義務化", + "what": "相続を知った日から3年以内の登記が必須に。…", + "why": "所有者不明土地の増加が社会問題化したため。…", + "impact": "相続人は期限内の手続きが必要。怠ると過料。…" + } + ], + "faq": [ + { "q": "相続登記の義務化はいつから?", "a": "2024年4月1日から施行されています。" } + ] +} +``` + +## レンダリング + +- 新コンポーネント `components/law-explainer.tsx`(page.tsx肥大化防止)。 +- 配置:`/law/[lawId]/page.tsx` の「この法律について」(`summary`)カードの直後。 +- 既存のTailwind/カードスタイル(`border`/`rounded-lg`/`bg-[var(--muted)]`/Iconコンポーネント)を踏襲。 +- 構成: + - `

{law_title}の最近の改正をわかりやすく

`(「○○法改正 わかりやすく」クエリに直撃する見出し) + - `intro` 段落 + - `recent_changes` を「年・タイトル」見出し+「何が変わった?」を表示。`why`/`impact`は値がある場合のみ表示(`grounded:false`では省略される) + - `faq` を Q&A リストで表示(`faq`が空配列なら非表示) + - AI生成の明示ラベル(about方針との一貫性) +- `explainer` が undefined の法令ではセクション自体を描画しない。 + +### meta description + +- `/law/[lawId]/page.tsx` の `generateMetadata` で、`explainer.intro` があればそれを description に優先採用(無ければ従来通り `summary.description`)。「最近の改正」「わかりやすく」がSERP説明文に載るようにする。 +- title は前コミット `c83dd62` で対応済み(`{法令名}の改正履歴|全N回の改正一覧`)。本specでは変更しない。 + +### 構造化データ(優先度・低) + +- **FAQリッチリザルトは政府機関・医療系の権威サイト限定で、lexdiffは対象外**。よってFAQPage JSON-LDはCTR改善の主要施策とせず、Phase1の成功条件・検証項目には含めない。 +- 実装としては、`faq`が1件以上ある場合に `FAQPage` JSON-LD を出力すること自体は任意(セマンティック補助)。Phase1では**スコープ外**とし、FAQ本文(オンページのテキスト)のみ提供する。将来必要になれば `components/faq-jsonld.tsx` で追加。 + +## 生成スクリプト `scripts/explainer.py` + +- `law_summary.py` を踏襲(`load_env`、`data/timelines/{law_id}.json`読み込み、生成、`data/` と `frontend/public/data/` の両方に書き戻し)。 + +### 入力の集約と根拠付け(hallucination対策の中核) + +timelineをそのまま渡さない。以下の前処理を行う: + +1. **改正法/トピック単位に集約**:同一 `amendment_law_title` が複数の施行日で並ぶケースや、技術的な整理法(「○○法の施行に伴う関係法律の整理等に関する法律」等)を1件に畳む。`diff_id:null`が大半なので、件数ではなく「市民に意味のある改正」を2-4件選ぶ。 +2. **diff有改正を優先&根拠付与**:`diff_id` のあるエントリは対応する `data/diffs/{diff_id}.json`(または `frontend/public/data/{diff_id}.json`)の `pr_summary`(title/summary/background/impact/key_changes)を入力に含め、`grounded:true` とする。これらは `why`/`impact` を生成してよい。 +3. **diff無改正は抑制**:`diff_id:null` のエントリは `amendment_law_title`・`enforcement_date` のみ確認済みとして扱い、`grounded:false`。`what` は改正法名から言える範囲に留め、**`why`/`impact` は生成しない(省略)**。 +4. recent_changes は新しい施行日順。`grounded:true`(実質的改正)を優先的に採用する。 + +- 入力プロンプト材料:`law_title`、`law_num`、`category`、`summary.description`、上記で集約・根拠付けした改正リスト。 +- 出力:`explainer` JSON(上記スキーマ)。コードフェンス除去・`json.loads`は`law_summary.py`と同じ堅牢化を流用。`faq`欠落/`null`は`[]`に、`why`/`impact`の空文字は未設定として正規化。 +- **モデル**:`claude-sonnet-4-6`(最新Sonnet。長文品質とコストのバランス。既存`law_summary.py`の`claude-sonnet-4-20250514`から更新)。 +- **プロンプト方針**:日本語SEO記事の作法に沿う。 + - 見出し・本文に検索意図の語(「○○法 改正」「わかりやすく」「いつから」)を自然に含める。 + - 専門用語を避け日常生活との関わりで説明(既存summaryの方針と一貫)。 + - **提供データに無い事実(背景・影響・数値・期限)は断定しない**。`grounded:false` の改正では背景/影響を書かない。不確実な点は書かない(書かない方を選ぶ)。 + - 各フィールドの文字数目安:intro 80-150字、what 40-100字、why/impact 各40-100字、faq.a 40-120字。 +- 既存の `requirements.txt` / `pyproject.toml`(anthropic)で追加依存なし。 + +## テスト・検証 + +### 自動チェック(機械的に落とす) +`explainer.py` 内(または小スクリプト)で生成JSONを検証し、満たさなければエラー終了: +- 必須キー(`intro`/`recent_changes`/`faq`)の存在と型。 +- `recent_changes` は1-4件、各要素に `year`/`title`/`what`/`grounded`。`grounded:false` の要素は `why`/`impact` を持たないこと。 +- 文字列長レンジ(intro/what/why/impact/faq)。空文字・`null`の混入なし(`faq`欠落は`[]`に正規化済み前提)。 +- `year` が4桁数字、対象法令の施行年と矛盾しない(timelineの年集合に含まれる)。 + +### 事実性レビューゲート(公開前必須・手動) +- Phase1の4法令は**1件ずつ人手でレビュー**し、各 `recent_changes`/`faq` を次の根拠と突合:施行日・改正法名(timeline)、`grounded:true`は `pr_summary`。 +- 突合できない断定(数値・期限・因果)が1つでもあれば、その記述を削るか再生成する。**未確認の記述を残したまま公開しない**。 +- AI生成の明示ラベルは既存about方針に準拠(免責は補助であって、公開可否の担保は上記レビューが負う)。 + +### ビルド・互換 +- `npm run build` がエラーなく通る。 +- ビルド出力 `out/law/{id}.html` に `

…わかりやすく

` と本文が含まれることを確認。 +- `explainer` 未生成の法令ページが従来通り描画される(後方互換)ことを確認。 + +## 成功条件・観測(Phase1) + +- **観測期間**:本番反映後 約2-3週間(Googleの再クロール・再評価を待つ)。GSC/GA4はmy-mcp-server(GSC alias `lexdiff`, GA4 property 528978508)で取得。 +- **主要指標**(4法令の `/law` ページに限定して観測): + - クリック数・CTR(最重要。現状ほぼ0からの改善)。 + - 「○○法 改正 わかりやすく」「○○法 改正 履歴」等クエリの掲載順位とクリック有無。 + - 表示回数(imp)とインデックス状況。 +- **判断基準**:4法令は母数が小さくCTRが揺れやすいため、CTR単独でなく「クリック数の増加」と「対象クエリでの順位上昇(特に2ページ目→1ページ目)」を併せて見る。明確な改善が見えれば残り法令へ横展開、横ばいならプロンプト/構成を見直す。 +- FAQリッチリザルトはlexdiffが対象外のため**成功条件に含めない**。 + +## 非対象(YAGNI) + +- 全法令への一括生成(Phase1の効果確認後)。 +- `/diff` ページ側のコンテンツ追加。 +- FAQPage JSON-LD(lexdiffはリッチリザルト対象外。将来必要時に追加)。 +- `recent_changes`/`faq` ごとの確認者・確認日・公開可否などのprovenance管理(Phase1は4法令を手動レビューするため過剰)。 +- 多言語・履歴差分の自動更新ワークフロー。 + +## 影響ファイル + +- 新規: `scripts/explainer.py`, `frontend/components/law-explainer.tsx` +- 変更: `frontend/lib/types.ts`, `frontend/app/law/[lawId]/page.tsx`(`law-explainer`描画+`generateMetadata`のdescriptionを`explainer.intro`優先に) +- データ: `data/timelines/{4法令}.json` と `frontend/public/data/timelines/{4法令}.json` diff --git a/frontend/app/law/[lawId]/page.tsx b/frontend/app/law/[lawId]/page.tsx index ab55c52..ee3712d 100644 --- a/frontend/app/law/[lawId]/page.tsx +++ b/frontend/app/law/[lawId]/page.tsx @@ -6,6 +6,7 @@ import { Icon } from "@/components/icon"; import { OpenGikaiLinks } from "@/components/opengikai-link"; import { getThemesForLaw } from "@/lib/life-themes"; import { BreadcrumbJsonLd } from "@/components/breadcrumb-jsonld"; +import { LawExplainerSection } from "@/components/law-explainer"; export function generateStaticParams() { return getTimelineIds().map((lawId) => ({ lawId })); @@ -19,6 +20,7 @@ export async function generateMetadata({ const { lawId } = await params; const data = getTimelineData(lawId); const desc = + data.explainer?.intro || data.summary?.description || `${data.law_title}の改正履歴を時系列で一覧。全${data.revision_count}回の改正について、いつ・どの条文が・どう変わったかをわかりやすく確認できます。`; return { @@ -152,6 +154,10 @@ export default async function LawPage({ )} + {data.explainer && ( + + )} +
{/* Git log — main content */}
diff --git a/frontend/components/law-explainer.tsx b/frontend/components/law-explainer.tsx new file mode 100644 index 0000000..0d8b713 --- /dev/null +++ b/frontend/components/law-explainer.tsx @@ -0,0 +1,75 @@ +import { Icon } from "@/components/icon"; +import type { LawExplainer } from "@/lib/types"; + +export function LawExplainerSection({ + lawTitle, + explainer, +}: { + lawTitle: string; + explainer: LawExplainer; +}) { + return ( +
+
+ +

+ {lawTitle}の最近の改正をわかりやすく +

+ AI要約 +
+
+

{explainer.intro}

+ +
+ {explainer.recent_changes.map((c, i) => ( +
+
+ + {c.year} + +

{c.title}

+
+
+
+
何が変わった?
+
{c.what}
+
+ {c.why && ( +
+
なぜ変わった?
+
{c.why}
+
+ )} + {c.impact && ( +
+
私たちへの影響
+
{c.impact}
+
+ )} +
+
+ ))} +
+ + {explainer.faq.length > 0 && ( +
+

よくある質問

+
+ {explainer.faq.map((f, i) => ( +
+

Q. {f.q}

+

+ A. {f.a} +

+
+ ))} +
+
+ )} +
+
+ ); +} diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index 259e522..f0efbec 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -96,6 +96,26 @@ export interface LawSummary { keywords: string[]; } +export interface ExplainerChange { + year: string; + title: string; + what: string; + why?: string; + impact?: string; + grounded: boolean; +} + +export interface ExplainerFaq { + q: string; + a: string; +} + +export interface LawExplainer { + intro: string; + recent_changes: ExplainerChange[]; + faq: ExplainerFaq[]; +} + export interface Contributor { name: string; position: string; @@ -113,4 +133,5 @@ export interface LawTimeline { summary?: LawSummary; category?: string; contributors?: Contributor[]; + explainer?: LawExplainer; } diff --git a/frontend/public/data/timelines/129AC0000000089.json b/frontend/public/data/timelines/129AC0000000089.json index 41d4b62..b519a41 100644 --- a/frontend/public/data/timelines/129AC0000000089.json +++ b/frontend/public/data/timelines/129AC0000000089.json @@ -529,5 +529,50 @@ "committee": "内閣委員会" } } - ] + ], + "explainer": { + "intro": "民法は近年、家族のあり方や財産に関するルールを時代の変化に合わせて見直す改正が続いています。離婚後の親権制度の大きな変更や、デジタル化への対応など、私たちの日常生活に直結する改正が相次いでいます。", + "recent_changes": [ + { + "year": "2026", + "title": "離婚後の共同親権制度の導入", + "what": "離婚後も父母の両方が親権者になれる「共同親権」制度が導入されます。また、財産分与の請求期間が2年から5年に延長され、養育費の支払いを守る新たな仕組みも設けられます(2026年施行)。", + "grounded": true, + "why": "離婚件数の増加や子どもの貧困問題が深刻化するなか、子の利益を最優先にする家族法制への見直しが求められていました。男女平等の推進や国際的な制度との整合性を図る必要もありました。", + "impact": "離婚後も両親が子育てに関わりやすくなり、子どもの生活環境の安定が期待されます。財産分与の請求期間が延びることで、離婚後の生活再建に向けた準備の時間も増えます。" + }, + { + "year": "2028", + "title": "民事手続きへのIT・デジタル技術の活用対応", + "what": "民事関係の手続きに情報通信技術(IT)を活用しやすくするため、民法の関連規定が整備されます(2028年施行)。", + "grounded": false + }, + { + "year": "2027", + "title": "譲渡担保・所有権留保に関する新法への対応", + "what": "「譲渡担保契約」や「所有権留保契約」を定めた新しい法律の施行に合わせて、民法の関連規定が整備・調整されます(2027年施行)。", + "grounded": false + }, + { + "year": "2025", + "title": "刑法改正に伴う民法関連規定の整理", + "what": "刑法などの改正に伴い、民法の関連する条文の表現や規定が整理・統一されます(2025年施行)。", + "grounded": false + } + ], + "faq": [ + { + "q": "共同親権はいつから始まりますか?", + "a": "2026年に施行予定です。離婚後の親権のルールが大きく変わりますので、離婚を検討中の方は事前に内容を確認しておくことをおすすめします。" + }, + { + "q": "財産分与の請求期間が延びるのはいつからですか?", + "a": "2026年施行の民法改正により、離婚後の財産分与を請求できる期間がこれまでの2年から5年に延長される予定です。" + }, + { + "q": "今回の民法改正は全員に関係しますか?", + "a": "すべての方に直接関係するわけではありませんが、離婚・子育て・財産・契約などに関わる方には影響が生じる場合があります。自分の状況に合わせて確認することをおすすめします。" + } + ] + } } \ No newline at end of file diff --git a/frontend/public/data/timelines/322AC0000000049.json b/frontend/public/data/timelines/322AC0000000049.json index 3f8a4b1..3d39127 100644 --- a/frontend/public/data/timelines/322AC0000000049.json +++ b/frontend/public/data/timelines/322AC0000000049.json @@ -172,5 +172,48 @@ "committee": "厚生労働委員会" } } - ] + ], + "explainer": { + "intro": "労働基準法は近年、働き方改革や育児・介護との両立支援を目的とした改正が続いています。中小企業への残業代ルールの統一や育休制度の拡充など、すべての働く人の環境を整えるための見直しが進んでいます。", + "recent_changes": [ + { + "year": "2024", + "title": "育児・介護休業制度の拡充", + "what": "育児や家族の介護をしながら働く人が利用しやすいよう、育児休業・介護休業の制度が改善され、2025年4月1日から企業に新たな対応が求められます。", + "grounded": true, + "why": "少子高齢化が進む中、現行制度では仕事と育児・介護の両立が難しいケースが増えており、誰もが安心して働き続けられる社会を目指して改正が行われました。", + "impact": "子育てや介護をしながら働く人にとって休業制度が使いやすくなり、仕事と家庭の両立がしやすくなります。企業にとっても人材の定着につながるメリットが期待されます。" + }, + { + "year": "2023", + "title": "中小企業にも深夜並みの残業代ルールを統一適用", + "what": "月60時間を超える残業に対して割増賃金率50%以上を支払う義務が、2023年4月からすべての企業に適用されました。これまで中小企業は適用が猶予されていました。", + "grounded": true, + "why": "企業規模によって労働条件に格差が生じていたため、大企業と同じ基準を中小企業にも広げることで、すべての労働者が公平に保護されるよう法整備が進められました。", + "impact": "中小企業で働く人も、月60時間超の残業をした場合により高い割増賃金を受け取れるようになります。企業側も長時間労働を抑制する動機が生まれ、労働環境の改善が期待されます。" + }, + { + "year": "2025", + "title": "刑法改正に伴う労働基準法の関連規定の整備(2025年)", + "what": "刑法等の改正施行に合わせて、労働基準法の関連する規定について必要な整理・見直しが行われました。", + "grounded": false + }, + { + "year": "2022", + "title": "刑法改正に伴う労働基準法の関連規定の整備(2022年)", + "what": "刑法等の改正施行に合わせて、労働基準法の関連する規定について必要な整理・見直しが行われました。", + "grounded": false + } + ], + "faq": [ + { + "q": "中小企業の残業代ルールはいつから変わりましたか?", + "a": "2023年4月1日から、月60時間超の残業に対する割増賃金率50%以上の義務がすべての中小企業にも適用されています。" + }, + { + "q": "育児・介護休業の改正はいつから始まりますか?", + "a": "育児・介護休業制度の拡充に関する改正は、2025年4月1日から施行される予定です。勤め先の対応についても確認してみましょう。" + } + ] + } } \ No newline at end of file diff --git a/frontend/public/data/timelines/335AC0000000105.json b/frontend/public/data/timelines/335AC0000000105.json index 9633f17..cb2ebd9 100644 --- a/frontend/public/data/timelines/335AC0000000105.json +++ b/frontend/public/data/timelines/335AC0000000105.json @@ -508,5 +508,46 @@ "committee": "内閣委員会" } } - ] + ], + "explainer": { + "intro": "道路交通法は、安全な交通環境の整備や社会の変化に対応するため、近年も継続的に改正が行われています。2025〜2026年にかけては、免許取得の仕組みや緊急車両への対応、デジタル化対応など幅広い分野での見直しが進んでいます。", + "recent_changes": [ + { + "year": "2026", + "title": "運転免許の早期取得制度の導入と緊急自動車への規制強化", + "what": "17歳6か月から運転免許試験を受験できるようになり、18歳で免許取得が可能になりました。また、緊急自動車の通行を妨げた場合の罰則が厳しくなりました。", + "grounded": true, + "why": "若者が進学・就職と同時に運転を始められるよう早期社会参加を支援するとともに、災害対応や救急医療の迅速化という社会的要請に応えるために改正されました。", + "impact": "高校生が卒業前に試験を受けておけるようになり、18歳になった日から運転できます。一方、救急車や消防車の進路を妨げた場合などの違反は、これまで以上に厳しく処罰されます。" + }, + { + "year": "2026", + "title": "出入国管理制度の改正に伴う道路交通法の関連規定の見直し", + "what": "出入国管理及び難民認定法等の改正に合わせて、道路交通法の関連する規定が整備・改正されました。", + "grounded": false + }, + { + "year": "2026", + "title": "デジタル化推進に伴う道路交通法の規定整備", + "what": "デジタル社会の形成を推進するための法整備の一環として、道路交通法の関連規定がデジタル化対応の観点から見直されました。", + "grounded": false + }, + { + "year": "2025", + "title": "刑法改正に伴う関連規定の整理", + "what": "刑法等の改正の施行に合わせて、道路交通法の関連する罰則規定などが整合性を保つよう整理・改正されました。", + "grounded": false + } + ], + "faq": [ + { + "q": "17歳でも運転免許の試験を受けられるようになるのはいつからですか?", + "a": "2026年施行の改正により、17歳6か月から試験を受験できるようになります。ただし、実際に免許を取得して運転できるのは18歳になってからです。" + }, + { + "q": "救急車や消防車の邪魔をすると、どうなりますか?", + "a": "2026年の改正で緊急自動車に対する交通規制が強化されており、その進路を妨害するなどの違反をした場合、改正前よりも厳しい罰則が適用されます。" + } + ] + } } \ No newline at end of file diff --git a/frontend/public/data/timelines/416AC0000000123.json b/frontend/public/data/timelines/416AC0000000123.json index cec4c3a..ab0a7c7 100644 --- a/frontend/public/data/timelines/416AC0000000123.json +++ b/frontend/public/data/timelines/416AC0000000123.json @@ -463,5 +463,50 @@ "party": "自由民主党", "count": 1 } - ] + ], + "explainer": { + "intro": "不動産登記法では近年、相続登記の義務化やデジタル化推進、個人情報保護の強化など、所有者不明土地問題の解決と登記制度の近代化に向けた改正が相次いでいます。", + "recent_changes": [ + { + "year": "2024", + "title": "相続登記の義務化とデジタル化・被害者保護の導入", + "what": "相続で不動産を取得した場合、知った日から3年以内に登記申請が義務となりました。また、法人は会社番号の登記、海外居住者は国内連絡先の登録が必要になり、DV・ストーカー被害者の住所を非公開にする制度も導入されました。", + "grounded": true, + "why": "所有者不明の土地が増え、公共事業や土地利用が滞る社会問題が深刻化していました。不動産登記法の改正により、登記情報の正確性向上と個人情報保護の両立が求められたためです。", + "impact": "相続した不動産を放置すると過料が課される可能性があるため、相続人は早めの手続きが必要です。一方、取引の安全性向上や被害者の住所秘匿制度により、多くの人にとって安心な制度となります。" + }, + { + "year": "2026", + "title": "公益信託に関する登記ルールの整備", + "what": "公益信託に関する法律の制定にあわせて、不動産登記法における公益信託に関連する登記手続きの規定が整備されました。", + "grounded": false + }, + { + "year": "2025", + "title": "刑法改正に伴う関係規定の整理", + "what": "刑法等の改正施行にあわせて、不動産登記法の関連条文について所要の文言整理・規定の整備が行われました。", + "grounded": false + }, + { + "year": "2022", + "title": "会社法改正に伴う登記関連規定の整備", + "what": "会社法の一部改正の施行にあわせて、不動産登記法における法人に関する登記手続きの関連規定が整備されました。", + "grounded": false + } + ], + "faq": [ + { + "q": "相続登記の義務化はいつから始まりましたか?", + "a": "2024年に施行された不動産登記法の改正により義務化されています。相続を知った日から3年以内に登記申請を行わないと過料の対象となる場合があります。" + }, + { + "q": "DV被害者の住所を非公開にするにはどうすればよいですか?", + "a": "今回の不動産登記法の改正で被害者保護のための住所秘匿制度が導入されました。具体的な手続きについては、最寄りの法務局にご相談ください。" + }, + { + "q": "海外に住んでいる場合、不動産登記で何か変わりましたか?", + "a": "2024年の改正により、海外居住者が不動産を所有する場合は国内の連絡先を登記することが義務付けられました。詳細は法務局でご確認ください。" + } + ] + } } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 6e81fde..0456aaf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,3 +7,8 @@ dependencies = [ "anthropic>=0.85.0", "httpx>=0.28.1", ] + +[dependency-groups] +dev = [ + "pytest>=9.0.3", +] diff --git a/scripts/explainer.py b/scripts/explainer.py new file mode 100644 index 0000000..af43633 --- /dev/null +++ b/scripts/explainer.py @@ -0,0 +1,293 @@ +"""Generate a plain-language 'recent amendments' explainer for a law using Claude AI. + +Usage: + python scripts/explainer.py + +Example: + python scripts/explainer.py 416AC0000000123 +""" + +import sys +import json +import os +import anthropic +from pathlib import Path + +MODEL = "claude-sonnet-4-6" +ROOT = Path(__file__).parent.parent +DATA_DIR = ROOT / "data" +FRONTEND_DIR = ROOT / "frontend" / "public" / "data" +MAX_CHANGES = 4 + + +def select_changes(timeline: list[dict]) -> list[dict]: + """Deterministically select up to MAX_CHANGES meaningful amendments. + + - dedupe by amendment_law_title (keep the diff_id-bearing entry, else latest date) + - grounded = bool(diff_id) + - order: grounded first, then enforcement_date descending + """ + by_title: dict[str, dict] = {} + for entry in timeline: + title = entry.get("amendment_law_title", "") + if not title: + continue + cand = { + "source_title": title, + "enforcement_date": entry.get("enforcement_date", "") or "", + "diff_id": entry.get("diff_id"), + } + cur = by_title.get(title) + if cur is None: + by_title[title] = cand + continue + cur_has, cand_has = bool(cur["diff_id"]), bool(cand["diff_id"]) + if (cand_has and not cur_has) or ( + cand_has == cur_has and cand["enforcement_date"] > cur["enforcement_date"] + ): + by_title[title] = cand + + changes = [ + { + "year": c["enforcement_date"][:4] if c["enforcement_date"] else "", + "source_title": c["source_title"], + "enforcement_date": c["enforcement_date"], + "diff_id": c["diff_id"], + "grounded": bool(c["diff_id"]), + } + for c in by_title.values() + ] + changes.sort(key=lambda c: (c["grounded"], c["enforcement_date"]), reverse=True) + + # Drop later entries that reuse a non-null diff_id already taken (same snapshot + # can back two amendment laws — show it once). None diff_ids are never collapsed. + deduped: list[dict] = [] + seen_diff_ids: set[str] = set() + for c in changes: + if c["diff_id"]: + if c["diff_id"] in seen_diff_ids: + continue + seen_diff_ids.add(c["diff_id"]) + deduped.append(c) + return deduped[:MAX_CHANGES] + + +def validate_explainer(explainer: dict, valid_years: set[str]) -> list[str]: + """Return a list of human-readable validation errors (empty = valid).""" + errors: list[str] = [] + for key in ("intro", "recent_changes", "faq"): + if key not in explainer: + errors.append(f"missing key: {key}") + if errors: + return errors + + intro = explainer["intro"] + if not isinstance(intro, str) or not (40 <= len(intro) <= 200): + errors.append("intro length out of range (40-200)") + + rc = explainer["recent_changes"] + if not isinstance(rc, list) or not (1 <= len(rc) <= MAX_CHANGES): + errors.append(f"recent_changes count out of range (1-{MAX_CHANGES})") + else: + for i, c in enumerate(rc): + if not isinstance(c, dict): + errors.append(f"recent_changes[{i}] not an object") + continue + for k in ("year", "title", "what", "grounded"): + if k not in c: + errors.append(f"recent_changes[{i}] missing {k}") + if c.get("year") not in valid_years: + errors.append(f"recent_changes[{i}] year {c.get('year')} not in timeline") + what = c.get("what", "") + if not isinstance(what, str) or not (0 < len(what) <= 120): + errors.append(f"recent_changes[{i}] what length out of range") + if not c.get("grounded") and (c.get("why") or c.get("impact")): + errors.append(f"recent_changes[{i}] ungrounded must not have why/impact") + + faq = explainer["faq"] + if not isinstance(faq, list): + errors.append("faq must be a list") + else: + if len(faq) > 3: + errors.append("faq count exceeds 3") + for i, f in enumerate(faq): + if not isinstance(f, dict): + errors.append(f"faq[{i}] not an object") + continue + if not f.get("q") or not f.get("a"): + errors.append(f"faq[{i}] missing q/a") + return errors + + +def normalize_explainer(raw: dict, selected: list[dict]) -> dict: + """Re-attach deterministic year/grounded from `selected`, keep LLM prose, + strip why/impact from ungrounded changes, normalize faq to a clean list.""" + changes = [] + for sel, item in zip(selected, raw.get("recent_changes", []) or []): + if not isinstance(item, dict): + continue + change = { + "year": sel["year"], + "title": (item.get("title") or sel["source_title"]).strip(), + "what": (item.get("what") or "").strip(), + "grounded": sel["grounded"], + } + if sel["grounded"]: + why = (item.get("why") or "").strip() + impact = (item.get("impact") or "").strip() + if why: + change["why"] = why + if impact: + change["impact"] = impact + changes.append(change) + + raw_faq = raw.get("faq") or [] + if not isinstance(raw_faq, list): + raw_faq = [] + faq = [ + {"q": f.get("q", "").strip(), "a": f.get("a", "").strip()} + for f in raw_faq + if isinstance(f, dict) and f.get("q") and f.get("a") + ] + return {"intro": (raw.get("intro") or "").strip(), "recent_changes": changes, "faq": faq} + + +def load_env(): + env_path = ROOT / ".env" + if env_path.exists(): + for line in env_path.read_text().splitlines(): + line = line.strip() + if line and not line.startswith("#") and "=" in line: + key, _, value = line.partition("=") + os.environ.setdefault(key.strip(), value.strip()) + + +def load_pr_summary(diff_id: str) -> dict | None: + """Load pr_summary for a diff_id from the frontend data dir or data/diffs.""" + for base in (FRONTEND_DIR, DATA_DIR / "diffs"): + path = base / f"{diff_id}.json" + if path.exists(): + data = json.loads(path.read_text()) + return data.get("pr_summary") + return None + + +def build_prompt(law_title, law_num, category, summary_desc, changes): + lines = [] + for i, c in enumerate(changes): + block = [f"[改正{i + 1}] 施行年: {c['year']} / 改正法: {c['source_title']}"] + if c["grounded"]: + pr = c.get("pr_summary") or {} + block.append(" 根拠あり(grounded)。以下の差分要約に基づき、what/why/impactを書いてよい:") + block.append(f" タイトル: {pr.get('title', '')}") + block.append(f" 概要: {pr.get('summary', '')}") + block.append(f" 背景: {pr.get('background', '')}") + block.append(f" 影響: {pr.get('impact', '')}") + else: + block.append(" 根拠なし(ungrounded)。改正法名と施行年のみ確認済み。" + "whatは改正法名から言える範囲に留め、why/impactは書かないこと。") + lines.append("\n".join(block)) + changes_text = "\n\n".join(lines) + + return f"""あなたは日本の法律をわかりやすく解説する専門家です。以下の法律の「最近の改正」を、一般市民向けにJSON形式で解説してください。 + +法令名: {law_title} +法令番号: {law_num} +分類: {category} +法律の概要: {summary_desc} + +最近の主要改正(この{len(changes)}件についてのみ書く。順番・件数を変えない): +{changes_text} + +出力JSON形式: +{{ + "intro": "この法律で近年どんな改正が続いているかの導入(80-150字、専門用語を避ける)", + "recent_changes": [ + {{"title": "改正の通称(例: 相続登記の義務化)", "what": "何が変わったか(40-100字)", "why": "なぜ変わったか(grounded時のみ・40-100字)", "impact": "私たちへの影響(grounded時のみ・40-100字)"}} + ], + "faq": [ + {{"q": "よくある質問(例: いつから?)", "a": "回答(40-120字)"}} + ] +}} + +ルール: +- recent_changes は入力の改正と同じ順番・同じ件数で出力する。 +- ungrounded の改正では why/impact を出力しない(キーごと省略)。 +- 提供情報に無い事実(数値・期限・因果)を断定しない。不確実なら書かない。 +- 見出しや本文に「{law_title}」「改正」「わかりやすく」が自然に含まれるとよい。 +- faq は0-3件。確実に答えられるものだけ。 +- JSONのみを出力する。""" + + +def generate_explainer(law_title, law_num, category, summary_desc, changes) -> dict: + client = anthropic.Anthropic() + prompt = build_prompt(law_title, law_num, category, summary_desc, changes) + response = client.messages.create( + model=MODEL, + max_tokens=2000, + messages=[{"role": "user", "content": prompt}], + ) + text = response.content[0].text.strip() + if text.startswith("```"): + text = text.split("\n", 1)[1].rsplit("```", 1)[0].strip() + return json.loads(text) + + +def main(): + if len(sys.argv) < 2: + print(__doc__) + sys.exit(1) + + load_env() + law_id = sys.argv[1] + + timeline_path = DATA_DIR / "timelines" / f"{law_id}.json" + if not timeline_path.exists(): + print(f"Error: Run timeline.py first for {law_id}") + sys.exit(1) + timeline = json.loads(timeline_path.read_text()) + + changes = select_changes(timeline["timeline"]) + if not changes: + print(f"Error: no usable amendments for {law_id}") + sys.exit(1) + for c in changes: + if c["grounded"] and c["diff_id"]: + c["pr_summary"] = load_pr_summary(c["diff_id"]) + if not c["pr_summary"]: + # diff file missing -> downgrade to ungrounded for safety + c["grounded"] = False + + print(f"Generating explainer for {timeline['law_title']} ({len(changes)} changes)...") + raw = generate_explainer( + timeline["law_title"], + timeline["law_num"], + timeline.get("category", ""), + (timeline.get("summary") or {}).get("description", ""), + changes, + ) + explainer = normalize_explainer(raw, changes) + + valid_years = {e.get("enforcement_date", "")[:4] for e in timeline["timeline"]} + errors = validate_explainer(explainer, valid_years) + if errors: + print("VALIDATION FAILED:") + for e in errors: + print(f" - {e}") + print("\nGenerated (not saved):") + print(json.dumps(explainer, ensure_ascii=False, indent=2)) + sys.exit(2) + + timeline["explainer"] = explainer + timeline_path.write_text(json.dumps(timeline, ensure_ascii=False, indent=2)) + frontend_path = FRONTEND_DIR / "timelines" / f"{law_id}.json" + if frontend_path.exists(): + frontend_path.write_text(json.dumps(timeline, ensure_ascii=False, indent=2)) + + print(f" intro: {explainer['intro'][:60]}...") + print(f" recent_changes: {len(explainer['recent_changes'])}, faq: {len(explainer['faq'])}") + print(f" Saved: {timeline_path}") + + +if __name__ == "__main__": + main() diff --git a/tests/test_explainer.py b/tests/test_explainer.py new file mode 100644 index 0000000..44fcefd --- /dev/null +++ b/tests/test_explainer.py @@ -0,0 +1,172 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "scripts")) + +from explainer import select_changes, validate_explainer, normalize_explainer + + +def test_select_dedupes_by_title_prefers_diff_id(): + timeline = [ + {"enforcement_date": "2024-04-01", "amendment_law_title": "民法等の一部を改正する法律", + "diff_id": "129_2024"}, + {"enforcement_date": "2026-04-01", "amendment_law_title": "民法等の一部を改正する法律", + "diff_id": None}, + ] + result = select_changes(timeline) + # same title collapses to one, the diff_id-bearing entry wins (grounded) + assert len(result) == 1 + assert result[0]["grounded"] is True + assert result[0]["diff_id"] == "129_2024" + assert result[0]["year"] == "2024" + + +def test_select_grounded_first_then_date_desc(): + timeline = [ + {"enforcement_date": "2020-01-01", "amendment_law_title": "A法", "diff_id": "d1"}, + {"enforcement_date": "2025-01-01", "amendment_law_title": "B法", "diff_id": None}, + {"enforcement_date": "2023-01-01", "amendment_law_title": "C法", "diff_id": None}, + ] + result = select_changes(timeline) + # grounded (A) first, then ungrounded by date desc (B 2025, C 2023) + assert [c["year"] for c in result] == ["2020", "2025", "2023"] + assert result[0]["grounded"] is True + assert result[1]["grounded"] is False + + +def test_select_caps_at_four_and_skips_empty_title(): + timeline = [{"enforcement_date": f"20{10+i}-01-01", + "amendment_law_title": f"法{i}", "diff_id": None} for i in range(6)] + timeline.append({"enforcement_date": "2030-01-01", "amendment_law_title": "", "diff_id": None}) + result = select_changes(timeline) + assert len(result) == 4 + assert all(c["year"] for c in result) + + +VALID_YEARS = {"2024", "2026"} + + +def _ok_explainer(): + return { + "intro": "不動産登記法は近年、相続登記の義務化など大きな改正が続いています。マイホームや相続の手続きに直接関わる重要な変更です。", + "recent_changes": [ + {"year": "2024", "title": "相続登記の義務化", "what": "相続を知った日から3年以内の登記が必須になりました。", + "why": "所有者不明土地の増加が社会問題化したためです。", "impact": "相続人は期限内の手続きが必要です。", "grounded": True}, + {"year": "2026", "title": "登記手続のデジタル化", "what": "オンラインでの手続きが拡充されました。", "grounded": False}, + ], + "faq": [{"q": "相続登記はいつから義務?", "a": "2024年4月1日から施行されています。"}], + } + + +def test_validate_passes_on_good_explainer(): + assert validate_explainer(_ok_explainer(), VALID_YEARS) == [] + + +def test_validate_flags_ungrounded_with_why(): + bad = _ok_explainer() + bad["recent_changes"][1]["why"] = "推測の背景" + errors = validate_explainer(bad, VALID_YEARS) + assert any("ungrounded" in e for e in errors) + + +def test_validate_flags_year_not_in_timeline(): + bad = _ok_explainer() + bad["recent_changes"][0]["year"] = "1999" + errors = validate_explainer(bad, VALID_YEARS) + assert any("year" in e for e in errors) + + +def test_validate_flags_too_many_faq(): + bad = _ok_explainer() + bad["faq"] = [{"q": f"Q{i}", "a": f"A{i}"} for i in range(4)] + errors = validate_explainer(bad, VALID_YEARS) + assert any("faq count exceeds 3" in e for e in errors) + + +def test_validate_flags_missing_key(): + bad = _ok_explainer() + del bad["intro"] + errors = validate_explainer(bad, VALID_YEARS) + assert any("intro" in e for e in errors) + + +def test_normalize_reattaches_year_grounded_and_strips_ungrounded(): + selected = [ + {"year": "2024", "source_title": "民法等の一部を改正する法律", "grounded": True}, + {"year": "2026", "source_title": "整理法", "grounded": False}, + ] + raw = { + "intro": " 導入文 ", + "recent_changes": [ + {"title": "相続登記の義務化", "what": "3年以内に登記。", "why": "所有者不明土地対策。", "impact": "過料あり。"}, + {"title": "技術的整理", "what": "条ずれの整理。", "why": "LLMが勝手に書いた背景", "impact": "影響も勝手に"}, + ], + # faq missing entirely + } + result = normalize_explainer(raw, selected) + assert result["intro"] == "導入文" + assert result["recent_changes"][0]["grounded"] is True + assert result["recent_changes"][0]["why"] == "所有者不明土地対策。" + # ungrounded: why/impact stripped + assert result["recent_changes"][1]["grounded"] is False + assert "why" not in result["recent_changes"][1] + assert "impact" not in result["recent_changes"][1] + # faq normalized to [] + assert result["faq"] == [] + + +def test_normalize_filters_incomplete_faq(): + selected = [{"year": "2024", "source_title": "X", "grounded": True}] + raw = {"intro": "x", "recent_changes": [{"title": "t", "what": "w", "why": "y", "impact": "i"}], + "faq": [{"q": "問", "a": "答"}, {"q": "", "a": "答だけ"}, {"q": "問だけ"}]} + result = normalize_explainer(raw, selected) + assert result["faq"] == [{"q": "問", "a": "答"}] + + +def test_validate_flags_non_dict_items_without_crashing(): + bad = _ok_explainer() + bad["recent_changes"][0] = "not a dict" + errors = validate_explainer(bad, VALID_YEARS) + assert any("recent_changes[0]" in e for e in errors) + + bad2 = _ok_explainer() + bad2["faq"] = ["not a dict"] + errors2 = validate_explainer(bad2, VALID_YEARS) + assert any("faq[0]" in e for e in errors2) + + +def test_select_dedupes_by_diff_id_across_titles(): + # Two different amendment titles share the SAME diff_id (same snapshot). + timeline = [ + {"enforcement_date": "2024-04-01", "amendment_law_title": "デジタル改革法", "diff_id": "416_2024"}, + {"enforcement_date": "2024-04-01", "amendment_law_title": "民法等改正", "diff_id": "416_2024"}, + {"enforcement_date": "2026-04-01", "amendment_law_title": "公益信託法", "diff_id": None}, + ] + result = select_changes(timeline) + diff_ids = [c["diff_id"] for c in result if c["diff_id"]] + assert diff_ids == ["416_2024"] # the shared diff_id appears only once + # the None-diff ungrounded entry survives + assert any(c["diff_id"] is None for c in result) + + +def test_select_does_not_collapse_distinct_none_diff_ids(): + timeline = [ + {"enforcement_date": "2025-01-01", "amendment_law_title": "A法", "diff_id": None}, + {"enforcement_date": "2024-01-01", "amendment_law_title": "B法", "diff_id": None}, + ] + result = select_changes(timeline) + assert len(result) == 2 # two None-diff entries are NOT merged + + +def test_normalize_skips_non_dict_change_items(): + selected = [ + {"year": "2024", "source_title": "X", "grounded": True}, + {"year": "2026", "source_title": "Y", "grounded": False}, + ] + raw = {"intro": "i", "recent_changes": ["garbage", {"title": "t", "what": "w"}], "faq": "notalist"} + result = normalize_explainer(raw, selected) + # non-dict change item is skipped, not crashed on; valid one is kept + assert all(isinstance(c, dict) for c in result["recent_changes"]) + assert any(c["title"] == "t" for c in result["recent_changes"]) + # non-list faq normalizes to [] + assert result["faq"] == []