Spaces:
Sleeping
Sleeping
pebaryan Claude Opus 4.7 commited on
Commit ·
3612cad
0
Parent(s):
Initial commit: legawa multi-agent system for Indonesian legislators
Browse filesFour agents (Analis RUU, Peneliti, Penyusun Naskah, Surat Konstituen)
running on two local llama.cpp endpoints (Qwen3 35B + 27B), with
pasal.id REST integration and a SQLite cache layer.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- .env.example +18 -0
- .gitignore +20 -0
- README.md +78 -0
- pyproject.toml +25 -0
- src/legawa/__init__.py +1 -0
- src/legawa/__main__.py +4 -0
- src/legawa/agents/__init__.py +0 -0
- src/legawa/agents/analis_ruu.py +88 -0
- src/legawa/agents/base.py +123 -0
- src/legawa/agents/peneliti.py +136 -0
- src/legawa/agents/penyusun.py +99 -0
- src/legawa/agents/surat.py +228 -0
- src/legawa/cli.py +175 -0
- src/legawa/config.py +55 -0
- src/legawa/llm.py +61 -0
- src/legawa/tools/__init__.py +0 -0
- src/legawa/tools/cache.py +158 -0
- src/legawa/tools/pasal.py +175 -0
.env.example
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# pasal.id API (https://pasal.id/api)
|
| 2 |
+
PASAL_API_TOKEN=your-bearer-token-here
|
| 3 |
+
PASAL_BASE_URL=https://pasal.id/api/v1
|
| 4 |
+
|
| 5 |
+
# llama.cpp endpoints (OpenAI-compatible)
|
| 6 |
+
# BIG: localhost, Qwen3 ~35B — synthesis, drafting, deep analysis
|
| 7 |
+
LLM_BIG_URL=http://localhost:8080/v1
|
| 8 |
+
LLM_BIG_MODEL=qwen3
|
| 9 |
+
LLM_BIG_API_KEY=sk-no-key-required
|
| 10 |
+
|
| 11 |
+
# SMALL: mi25 over LAN, Qwen3 ~27B — classification, extraction, query expansion
|
| 12 |
+
LLM_SMALL_URL=http://mi25:8080/v1
|
| 13 |
+
LLM_SMALL_MODEL=qwen3
|
| 14 |
+
LLM_SMALL_API_KEY=sk-no-key-required
|
| 15 |
+
|
| 16 |
+
# Generation defaults
|
| 17 |
+
LLM_TEMPERATURE=0.3
|
| 18 |
+
LLM_MAX_TOKENS=4096
|
.gitignore
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
.venv/
|
| 3 |
+
venv/
|
| 4 |
+
__pycache__/
|
| 5 |
+
*.pyc
|
| 6 |
+
*.pyo
|
| 7 |
+
.pytest_cache/
|
| 8 |
+
.ruff_cache/
|
| 9 |
+
.mypy_cache/
|
| 10 |
+
dist/
|
| 11 |
+
build/
|
| 12 |
+
*.egg-info/
|
| 13 |
+
.idea/
|
| 14 |
+
.vscode/
|
| 15 |
+
output/
|
| 16 |
+
*.pdf
|
| 17 |
+
!docs/*.pdf
|
| 18 |
+
|
| 19 |
+
# Claude Code per-user settings (project-level settings.json is fine to commit)
|
| 20 |
+
.claude/settings.local.json
|
README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# legawa
|
| 2 |
+
|
| 3 |
+
Asisten multi-agen untuk anggota legislatif Indonesia (DPR/DPRD). Berjalan di atas dua instance llama.cpp lokal (Qwen3) dan menggunakan API publik [pasal.id](https://pasal.id) sebagai sumber peraturan perundang-undangan.
|
| 4 |
+
|
| 5 |
+
## Arsitektur
|
| 6 |
+
|
| 7 |
+
```
|
| 8 |
+
┌──────────────────────┐
|
| 9 |
+
pengguna (CLI) ─────▶│ Typer orchestrator │
|
| 10 |
+
└──────────┬───────────┘
|
| 11 |
+
│
|
| 12 |
+
┌────────────────────┼─────────────────────┐
|
| 13 |
+
▼ ▼ ▼
|
| 14 |
+
┌────────────────┐ ┌──────────────────┐ ┌────────────────────┐
|
| 15 |
+
│ Analis RUU │ │ Peneliti Hukum │ │ Penyusun Naskah │
|
| 16 |
+
│ (BIG, tools) │ │ (SMALL→BIG) │ │ (BIG, opt. riset) │
|
| 17 |
+
└────────┬───────┘ └─────────┬────────┘ └─────────┬──────────┘
|
| 18 |
+
│ │ │
|
| 19 |
+
└──────────┬─────────┴─────────────────────┘
|
| 20 |
+
▼
|
| 21 |
+
┌────────────────────┐
|
| 22 |
+
│ pasal.id REST │ search · list · get_law
|
| 23 |
+
└────────────────────┘
|
| 24 |
+
```
|
| 25 |
+
|
| 26 |
+
Routing model:
|
| 27 |
+
- **BIG** (`localhost:8080`, Qwen3 ~35B) — sintesis, drafting, analisis pasal-per-pasal.
|
| 28 |
+
- **SMALL** (`mi25:8080`, Qwen3 ~27B) — klasifikasi, ekstraksi, ekspansi query.
|
| 29 |
+
|
| 30 |
+
## Setup
|
| 31 |
+
|
| 32 |
+
```bash
|
| 33 |
+
cp .env.example .env
|
| 34 |
+
# isi PASAL_API_TOKEN dan sesuaikan URL llama.cpp jika perlu
|
| 35 |
+
|
| 36 |
+
pip install -e .
|
| 37 |
+
legawa health
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
## Pemakaian
|
| 41 |
+
|
| 42 |
+
```bash
|
| 43 |
+
# Analisis RUU dari PDF
|
| 44 |
+
legawa analyze ./ruu-cipta-kerja.pdf --out output/analisis.md
|
| 45 |
+
|
| 46 |
+
# Riset hukum bertema
|
| 47 |
+
legawa research "perlindungan data pribadi sektor kesehatan" -o output/riset.md
|
| 48 |
+
|
| 49 |
+
# Susun pidato/memo/naskah akademik
|
| 50 |
+
legawa draft pidato "urgensi RUU Masyarakat Adat" -o output/pidato.md
|
| 51 |
+
legawa draft memo_kebijakan "subsidi BBM 2026" --no-research
|
| 52 |
+
legawa draft naskah_akademik "perubahan UU Pemilu" -i "fokus pada presidential threshold"
|
| 53 |
+
|
| 54 |
+
# Triase + balasan surat konstituen
|
| 55 |
+
legawa surat ./inbox/aspirasi-001.txt -o output/balasan.md
|
| 56 |
+
legawa surat ./inbox/aspirasi-001.txt --triase-only
|
| 57 |
+
|
| 58 |
+
# Kelola cache pasal.id
|
| 59 |
+
legawa cache stats
|
| 60 |
+
legawa cache purge # hapus entri expired
|
| 61 |
+
```
|
| 62 |
+
|
| 63 |
+
## Cache pasal.id
|
| 64 |
+
|
| 65 |
+
Semua panggilan REST pasal.id dilewatkan melalui SQLite cache:
|
| 66 |
+
|
| 67 |
+
| endpoint | TTL |
|
| 68 |
+
|-----------|-----------|
|
| 69 |
+
| `search` | 1 jam |
|
| 70 |
+
| `laws` | 1 jam |
|
| 71 |
+
| `laws/{}` | 24 jam |
|
| 72 |
+
|
| 73 |
+
Lokasi default: `~/.legawa/cache.db`. Override dengan `LEGAWA_CACHE_PATH=/custom/path.db`.
|
| 74 |
+
|
| 75 |
+
## Catatan
|
| 76 |
+
|
| 77 |
+
- Setiap klaim agen tentang peraturan existing dilengkapi `frbr_uri` pasal.id agar dapat diverifikasi.
|
| 78 |
+
- Output tidak menggantikan tinjauan hukum profesional. Selalu review oleh staf ahli sebelum publikasi.
|
pyproject.toml
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["hatchling"]
|
| 3 |
+
build-backend = "hatchling.build"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "legawa"
|
| 7 |
+
version = "0.1.0"
|
| 8 |
+
description = "Multi-agent assistant for Indonesian legislators (DPR/DPRD)"
|
| 9 |
+
readme = "README.md"
|
| 10 |
+
requires-python = ">=3.11"
|
| 11 |
+
dependencies = [
|
| 12 |
+
"openai>=1.50.0",
|
| 13 |
+
"httpx>=0.27.0",
|
| 14 |
+
"typer>=0.12.0",
|
| 15 |
+
"rich>=13.7.0",
|
| 16 |
+
"python-dotenv>=1.0.0",
|
| 17 |
+
"pypdf>=4.3.0",
|
| 18 |
+
"pydantic>=2.7.0",
|
| 19 |
+
]
|
| 20 |
+
|
| 21 |
+
[project.scripts]
|
| 22 |
+
legawa = "legawa.cli:app"
|
| 23 |
+
|
| 24 |
+
[tool.hatch.build.targets.wheel]
|
| 25 |
+
packages = ["src/legawa"]
|
src/legawa/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
__version__ = "0.1.0"
|
src/legawa/__main__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .cli import app
|
| 2 |
+
|
| 3 |
+
if __name__ == "__main__":
|
| 4 |
+
app()
|
src/legawa/agents/__init__.py
ADDED
|
File without changes
|
src/legawa/agents/analis_ruu.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Analis RUU — agent yang menganalisis Rancangan Undang-Undang.
|
| 2 |
+
|
| 3 |
+
Input: teks atau path PDF RUU.
|
| 4 |
+
Output:
|
| 5 |
+
1. Ringkasan eksekutif
|
| 6 |
+
2. Analisis pasal-per-pasal
|
| 7 |
+
3. Daftar UU/peraturan yang dirujuk + status keberlakuan
|
| 8 |
+
4. Potensi konflik atau tumpang tindih dengan peraturan existing
|
| 9 |
+
5. Catatan untuk legislator (poin pertimbangan politik/hukum)
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
from rich.console import Console
|
| 17 |
+
|
| 18 |
+
from ..llm import LLMPool
|
| 19 |
+
from ..tools.pasal import PasalClient, TOOL_SCHEMAS, build_dispatcher
|
| 20 |
+
from .base import AgentResult, ToolAgent
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
SYSTEM_PROMPT = """\
|
| 24 |
+
Anda adalah Analis RUU senior yang membantu anggota legislatif Indonesia (DPR/DPRD)
|
| 25 |
+
mengevaluasi rancangan peraturan perundang-undangan.
|
| 26 |
+
|
| 27 |
+
Tugas Anda untuk setiap RUU yang diberikan:
|
| 28 |
+
1. **Ringkasan Eksekutif** — 3–5 kalimat tentang tujuan, ruang lingkup, dan dampak utama RUU.
|
| 29 |
+
2. **Analisis Pasal-per-Pasal** — untuk pasal-pasal kunci, jelaskan maksud, implikasi praktis,
|
| 30 |
+
dan ambiguitas yang berpotensi menimbulkan masalah implementasi.
|
| 31 |
+
3. **Peraturan yang Dirujuk** — identifikasi UU/PP/Perpres yang disebut atau secara substantif
|
| 32 |
+
relevan. Gunakan tool `pasal_search` untuk memverifikasi keberadaan dan status keberlakuannya
|
| 33 |
+
(berlaku/dicabut/diubah). Tampilkan frbr_uri sebagai sitasi.
|
| 34 |
+
4. **Potensi Konflik / Tumpang Tindih** — gunakan `pasal_search` dan `pasal_get_law` untuk
|
| 35 |
+
memeriksa peraturan terkait. Soroti pasal yang berpotensi bertentangan atau duplikatif,
|
| 36 |
+
sertakan kutipan pasal pembanding.
|
| 37 |
+
5. **Catatan untuk Legislator** — poin pertimbangan: dampak fiskal, dampak konstituen,
|
| 38 |
+
isu HAM, kewenangan kelembagaan, kelayakan implementasi.
|
| 39 |
+
|
| 40 |
+
Aturan:
|
| 41 |
+
- WAJIB sitasi: setiap klaim tentang peraturan existing harus disertai frbr_uri dan kutipan
|
| 42 |
+
pasal yang relevan dari hasil tool. Jangan mengarang nomor UU.
|
| 43 |
+
- Tulis seluruh analisis dalam Bahasa Indonesia formal, gaya legal-tehnis.
|
| 44 |
+
- Gunakan tool secara agresif. Cari dahulu, sintesis kemudian. Lakukan setidaknya 3 pencarian
|
| 45 |
+
untuk RUU bertema substansial.
|
| 46 |
+
- Output akhir dalam format Markdown dengan heading yang jelas.
|
| 47 |
+
"""
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def load_ruu_text(source: str) -> str:
|
| 51 |
+
"""Load RUU content from a file path (txt/md/pdf) or treat as raw text."""
|
| 52 |
+
path = Path(source)
|
| 53 |
+
if not path.exists() or not path.is_file():
|
| 54 |
+
return source
|
| 55 |
+
|
| 56 |
+
if path.suffix.lower() == ".pdf":
|
| 57 |
+
from pypdf import PdfReader
|
| 58 |
+
|
| 59 |
+
reader = PdfReader(str(path))
|
| 60 |
+
return "\n\n".join(page.extract_text() or "" for page in reader.pages)
|
| 61 |
+
return path.read_text(encoding="utf-8")
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def build_agent(pool: LLMPool, pasal: PasalClient, console: Console | None = None) -> ToolAgent:
|
| 65 |
+
return ToolAgent(
|
| 66 |
+
name="analis-ruu",
|
| 67 |
+
llm=pool.big,
|
| 68 |
+
system_prompt=SYSTEM_PROMPT,
|
| 69 |
+
tools=TOOL_SCHEMAS,
|
| 70 |
+
dispatcher=build_dispatcher(pasal),
|
| 71 |
+
max_iters=12,
|
| 72 |
+
console=console,
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def analyze(
|
| 77 |
+
pool: LLMPool,
|
| 78 |
+
pasal: PasalClient,
|
| 79 |
+
source: str,
|
| 80 |
+
*,
|
| 81 |
+
console: Console | None = None,
|
| 82 |
+
) -> AgentResult:
|
| 83 |
+
text = load_ruu_text(source)
|
| 84 |
+
if len(text) > 60000:
|
| 85 |
+
text = text[:60000] + "\n\n[... teks dipotong karena panjang ...]"
|
| 86 |
+
agent = build_agent(pool, pasal, console=console)
|
| 87 |
+
user_input = f"Analisis RUU berikut:\n\n---\n{text}\n---"
|
| 88 |
+
return agent.run(user_input)
|
src/legawa/agents/base.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Generic tool-calling agent loop.
|
| 2 |
+
|
| 3 |
+
Drives an OpenAI-compatible chat model (llama.cpp + Qwen3) through repeated
|
| 4 |
+
tool calls until the model returns a final assistant message with no tool calls.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
from dataclasses import dataclass, field
|
| 11 |
+
from typing import Any, Callable
|
| 12 |
+
|
| 13 |
+
from rich.console import Console
|
| 14 |
+
|
| 15 |
+
from ..llm import LLM
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
ToolDispatcher = Callable[[str, dict[str, Any]], Any]
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@dataclass
|
| 22 |
+
class AgentResult:
|
| 23 |
+
output: str
|
| 24 |
+
messages: list[dict[str, Any]]
|
| 25 |
+
tool_calls: list[dict[str, Any]] = field(default_factory=list)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class ToolAgent:
|
| 29 |
+
def __init__(
|
| 30 |
+
self,
|
| 31 |
+
name: str,
|
| 32 |
+
llm: LLM,
|
| 33 |
+
system_prompt: str,
|
| 34 |
+
tools: list[dict[str, Any]],
|
| 35 |
+
dispatcher: ToolDispatcher,
|
| 36 |
+
*,
|
| 37 |
+
max_iters: int = 8,
|
| 38 |
+
console: Console | None = None,
|
| 39 |
+
):
|
| 40 |
+
self.name = name
|
| 41 |
+
self.llm = llm
|
| 42 |
+
self.system_prompt = system_prompt
|
| 43 |
+
self.tools = tools
|
| 44 |
+
self.dispatcher = dispatcher
|
| 45 |
+
self.max_iters = max_iters
|
| 46 |
+
self.console = console or Console()
|
| 47 |
+
|
| 48 |
+
def run(self, user_input: str) -> AgentResult:
|
| 49 |
+
messages: list[dict[str, Any]] = [
|
| 50 |
+
{"role": "system", "content": self.system_prompt},
|
| 51 |
+
{"role": "user", "content": user_input},
|
| 52 |
+
]
|
| 53 |
+
all_calls: list[dict[str, Any]] = []
|
| 54 |
+
|
| 55 |
+
for _ in range(self.max_iters):
|
| 56 |
+
msg = self.llm.chat_with_tools(messages, self.tools)
|
| 57 |
+
tool_calls = getattr(msg, "tool_calls", None) or []
|
| 58 |
+
|
| 59 |
+
assistant_entry: dict[str, Any] = {
|
| 60 |
+
"role": "assistant",
|
| 61 |
+
"content": msg.content or "",
|
| 62 |
+
}
|
| 63 |
+
if tool_calls:
|
| 64 |
+
assistant_entry["tool_calls"] = [
|
| 65 |
+
{
|
| 66 |
+
"id": tc.id,
|
| 67 |
+
"type": "function",
|
| 68 |
+
"function": {
|
| 69 |
+
"name": tc.function.name,
|
| 70 |
+
"arguments": tc.function.arguments,
|
| 71 |
+
},
|
| 72 |
+
}
|
| 73 |
+
for tc in tool_calls
|
| 74 |
+
]
|
| 75 |
+
messages.append(assistant_entry)
|
| 76 |
+
|
| 77 |
+
if not tool_calls:
|
| 78 |
+
return AgentResult(output=msg.content or "", messages=messages, tool_calls=all_calls)
|
| 79 |
+
|
| 80 |
+
for tc in tool_calls:
|
| 81 |
+
fn_name = tc.function.name
|
| 82 |
+
try:
|
| 83 |
+
fn_args = json.loads(tc.function.arguments or "{}")
|
| 84 |
+
except json.JSONDecodeError as e:
|
| 85 |
+
fn_args = {}
|
| 86 |
+
err = f"invalid JSON in tool args: {e}"
|
| 87 |
+
self.console.print(f"[red]{self.name}: {err}[/red]")
|
| 88 |
+
messages.append({
|
| 89 |
+
"role": "tool",
|
| 90 |
+
"tool_call_id": tc.id,
|
| 91 |
+
"content": json.dumps({"error": err}),
|
| 92 |
+
})
|
| 93 |
+
continue
|
| 94 |
+
|
| 95 |
+
self.console.print(
|
| 96 |
+
f"[dim]{self.name} → {fn_name}({json.dumps(fn_args, ensure_ascii=False)[:160]})[/dim]"
|
| 97 |
+
)
|
| 98 |
+
try:
|
| 99 |
+
result = self.dispatcher(fn_name, fn_args)
|
| 100 |
+
except Exception as e: # noqa: BLE001
|
| 101 |
+
result = {"error": str(e)}
|
| 102 |
+
self.console.print(f"[red]{self.name} tool error: {e}[/red]")
|
| 103 |
+
|
| 104 |
+
all_calls.append({"name": fn_name, "args": fn_args, "result": result})
|
| 105 |
+
messages.append({
|
| 106 |
+
"role": "tool",
|
| 107 |
+
"tool_call_id": tc.id,
|
| 108 |
+
"content": json.dumps(_truncate(result), ensure_ascii=False),
|
| 109 |
+
})
|
| 110 |
+
|
| 111 |
+
return AgentResult(
|
| 112 |
+
output="(agent reached max iterations without final answer)",
|
| 113 |
+
messages=messages,
|
| 114 |
+
tool_calls=all_calls,
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _truncate(obj: Any, max_len: int = 24000) -> Any:
|
| 119 |
+
"""Cap tool-result payload size to avoid blowing the context window."""
|
| 120 |
+
s = json.dumps(obj, ensure_ascii=False)
|
| 121 |
+
if len(s) <= max_len:
|
| 122 |
+
return obj
|
| 123 |
+
return {"_truncated": True, "preview": s[:max_len]}
|
src/legawa/agents/peneliti.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Peneliti Hukum — riset hukum bertema.
|
| 2 |
+
|
| 3 |
+
Pipeline:
|
| 4 |
+
1. SMALL model: ekspansi topik → 4–6 query pencarian (Bahasa Indonesia, beragam sudut).
|
| 5 |
+
2. PasalClient: jalankan semua query, kumpulkan hits unik.
|
| 6 |
+
3. BIG model: sintesis temuan dengan sitasi frbr_uri + kutipan pasal.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 13 |
+
from typing import Any
|
| 14 |
+
|
| 15 |
+
from rich.console import Console
|
| 16 |
+
|
| 17 |
+
from ..llm import LLMPool
|
| 18 |
+
from ..tools.pasal import PasalClient
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
QUERY_EXPANSION_PROMPT = """\
|
| 22 |
+
Anda membantu riset hukum Indonesia. Diberikan satu topik, hasilkan 4–6 query pencarian
|
| 23 |
+
berbeda untuk database peraturan (pasal.id). Variasikan sudut pandang: subjek hukum,
|
| 24 |
+
istilah teknis, sinonim, dan konteks kelembagaan.
|
| 25 |
+
|
| 26 |
+
Output WAJIB berupa JSON array of strings, tanpa teks lain. Contoh:
|
| 27 |
+
["query 1", "query 2", "query 3"]
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
SYNTHESIS_PROMPT = """\
|
| 31 |
+
Anda adalah peneliti hukum senior yang membantu legislator Indonesia.
|
| 32 |
+
Diberikan topik riset dan kumpulan hasil pencarian peraturan dari pasal.id, susun memo riset
|
| 33 |
+
dalam Bahasa Indonesia formal dengan struktur:
|
| 34 |
+
|
| 35 |
+
# Memo Riset Hukum: <topik>
|
| 36 |
+
|
| 37 |
+
## 1. Ringkasan Temuan
|
| 38 |
+
3–5 kalimat yang menjawab inti topik.
|
| 39 |
+
|
| 40 |
+
## 2. Peraturan Utama
|
| 41 |
+
Untuk setiap peraturan kunci: judul, jenis, nomor/tahun, status, frbr_uri, dan ringkasan
|
| 42 |
+
kontribusinya terhadap topik. Sertakan kutipan pasal pendek bila tersedia.
|
| 43 |
+
|
| 44 |
+
## 3. Hubungan Antar Peraturan
|
| 45 |
+
Identifikasi hierarki, perubahan, pencabutan, atau saling-rujuk antar peraturan.
|
| 46 |
+
|
| 47 |
+
## 4. Celah / Pertanyaan Terbuka
|
| 48 |
+
Aspek yang belum tercover oleh peraturan existing atau memerlukan riset lanjutan.
|
| 49 |
+
|
| 50 |
+
## 5. Rekomendasi Tindak Lanjut untuk Legislator
|
| 51 |
+
2–4 poin praktis (mis. usulan revisi, pertanyaan untuk RDP, dapil-impact).
|
| 52 |
+
|
| 53 |
+
Aturan:
|
| 54 |
+
- Setiap klaim tentang isi peraturan harus berasal dari hasil pencarian; cantumkan frbr_uri.
|
| 55 |
+
- Jangan mengarang nomor UU atau pasal yang tidak ada di hasil.
|
| 56 |
+
- Bila hasil tidak cukup, katakan secara eksplisit di bagian "Celah".
|
| 57 |
+
"""
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _expand_queries(pool: LLMPool, topic: str, console: Console) -> list[str]:
|
| 61 |
+
raw = pool.small.chat(
|
| 62 |
+
[
|
| 63 |
+
{"role": "system", "content": QUERY_EXPANSION_PROMPT},
|
| 64 |
+
{"role": "user", "content": f"Topik: {topic}"},
|
| 65 |
+
],
|
| 66 |
+
temperature=0.4,
|
| 67 |
+
max_tokens=512,
|
| 68 |
+
)
|
| 69 |
+
# Be tolerant of model wrapping JSON in code fences.
|
| 70 |
+
cleaned = raw.strip()
|
| 71 |
+
if cleaned.startswith("```"):
|
| 72 |
+
cleaned = cleaned.strip("`")
|
| 73 |
+
if cleaned.lower().startswith("json"):
|
| 74 |
+
cleaned = cleaned[4:].strip()
|
| 75 |
+
try:
|
| 76 |
+
queries = json.loads(cleaned)
|
| 77 |
+
if isinstance(queries, list) and all(isinstance(q, str) for q in queries):
|
| 78 |
+
return queries[:6]
|
| 79 |
+
except json.JSONDecodeError:
|
| 80 |
+
pass
|
| 81 |
+
console.print(f"[yellow]peneliti: gagal parse query expansion, fallback ke topik mentah[/yellow]")
|
| 82 |
+
return [topic]
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _dedupe_hits(results: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 86 |
+
seen: set[str] = set()
|
| 87 |
+
unique: list[dict[str, Any]] = []
|
| 88 |
+
for batch in results:
|
| 89 |
+
for hit in batch.get("results", []) or batch.get("hits", []) or []:
|
| 90 |
+
key = (
|
| 91 |
+
hit.get("frbr_uri")
|
| 92 |
+
or hit.get("work", {}).get("frbr_uri")
|
| 93 |
+
or json.dumps(hit, sort_keys=True)[:120]
|
| 94 |
+
)
|
| 95 |
+
if key in seen:
|
| 96 |
+
continue
|
| 97 |
+
seen.add(key)
|
| 98 |
+
unique.append(hit)
|
| 99 |
+
return unique
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def research(
|
| 103 |
+
pool: LLMPool,
|
| 104 |
+
pasal: PasalClient,
|
| 105 |
+
topic: str,
|
| 106 |
+
*,
|
| 107 |
+
console: Console | None = None,
|
| 108 |
+
) -> str:
|
| 109 |
+
console = console or Console()
|
| 110 |
+
console.print(f"[cyan]peneliti: ekspansi query untuk topik '{topic}'[/cyan]")
|
| 111 |
+
queries = _expand_queries(pool, topic, console)
|
| 112 |
+
console.print(f"[dim]queries: {queries}[/dim]")
|
| 113 |
+
|
| 114 |
+
console.print(f"[cyan]peneliti: menjalankan {len(queries)} pencarian paralel[/cyan]")
|
| 115 |
+
with ThreadPoolExecutor(max_workers=min(6, len(queries))) as ex:
|
| 116 |
+
batches = list(ex.map(lambda q: pasal.search(q, limit=10), queries))
|
| 117 |
+
|
| 118 |
+
hits = _dedupe_hits(batches)
|
| 119 |
+
console.print(f"[cyan]peneliti: {len(hits)} hit unik, sintesis dengan model besar[/cyan]")
|
| 120 |
+
|
| 121 |
+
payload = {"topic": topic, "queries": queries, "hits": hits[:40]}
|
| 122 |
+
output = pool.big.chat(
|
| 123 |
+
[
|
| 124 |
+
{"role": "system", "content": SYNTHESIS_PROMPT},
|
| 125 |
+
{
|
| 126 |
+
"role": "user",
|
| 127 |
+
"content": (
|
| 128 |
+
f"Topik: {topic}\n\n"
|
| 129 |
+
f"Hasil pencarian (JSON):\n{json.dumps(payload, ensure_ascii=False)[:50000]}"
|
| 130 |
+
),
|
| 131 |
+
},
|
| 132 |
+
],
|
| 133 |
+
temperature=0.3,
|
| 134 |
+
max_tokens=4096,
|
| 135 |
+
)
|
| 136 |
+
return output
|
src/legawa/agents/penyusun.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Penyusun Naskah — drafter pidato, naskah akademik, dan memo kebijakan.
|
| 2 |
+
|
| 3 |
+
Alur:
|
| 4 |
+
1. Jika user butuh dukungan riset, panggil Peneliti dulu untuk ringkasan basis hukum.
|
| 5 |
+
2. BIG model menyusun naskah final dengan gaya yang sesuai jenis dokumen.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from typing import Literal
|
| 11 |
+
|
| 12 |
+
from rich.console import Console
|
| 13 |
+
|
| 14 |
+
from ..llm import LLMPool
|
| 15 |
+
from ..tools.pasal import PasalClient
|
| 16 |
+
from . import peneliti
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
NaskahKind = Literal["pidato", "naskah_akademik", "memo_kebijakan", "siaran_pers"]
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
STYLE_GUIDES: dict[str, str] = {
|
| 23 |
+
"pidato": (
|
| 24 |
+
"Pidato legislatif untuk dibacakan di hadapan rapat paripurna atau publik. "
|
| 25 |
+
"Gunakan retorika yang jelas, kalimat ritmis, sapaan formal ('Yang terhormat...'), "
|
| 26 |
+
"dan tutup dengan ajakan/komitmen. Panjang 600–900 kata."
|
| 27 |
+
),
|
| 28 |
+
"naskah_akademik": (
|
| 29 |
+
"Naskah Akademik untuk RUU sesuai UU 12/2011 jo. UU 13/2022. Struktur: "
|
| 30 |
+
"Bab I Pendahuluan (latar belakang, identifikasi masalah, tujuan, metode); "
|
| 31 |
+
"Bab II Kajian Teoritis & Praktik Empiris; Bab III Evaluasi Peraturan Terkait; "
|
| 32 |
+
"Bab IV Landasan Filosofis/Sosiologis/Yuridis; Bab V Jangkauan, Arah Pengaturan, "
|
| 33 |
+
"Materi Muatan; Bab VI Penutup. Bahasa formal akademis."
|
| 34 |
+
),
|
| 35 |
+
"memo_kebijakan": (
|
| 36 |
+
"Memo kebijakan internal 1–2 halaman: TL;DR (3 kalimat), Latar Belakang, "
|
| 37 |
+
"Pilihan Kebijakan dengan trade-off, Rekomendasi, Risiko & Mitigasi. Padat, action-oriented."
|
| 38 |
+
),
|
| 39 |
+
"siaran_pers": (
|
| 40 |
+
"Siaran pers 250–400 kata dengan headline kuat, lead paragraf yang menjawab 5W1H, "
|
| 41 |
+
"kutipan dari anggota legislatif (placeholder [NAMA ANGGOTA]), dan boilerplate penutup."
|
| 42 |
+
),
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
SYSTEM_TEMPLATE = """\
|
| 47 |
+
Anda adalah Penyusun Naskah profesional yang membantu anggota legislatif Indonesia.
|
| 48 |
+
|
| 49 |
+
Jenis dokumen: {kind}
|
| 50 |
+
Pedoman gaya: {style}
|
| 51 |
+
|
| 52 |
+
Aturan umum:
|
| 53 |
+
- Bahasa Indonesia formal, presisi, dan menghormati kaidah hukum.
|
| 54 |
+
- Bila diberikan basis riset (bagian "BASIS RISET"), pakai sebagai sumber fakta. Sertakan
|
| 55 |
+
sitasi peraturan dengan format: "UU No. X Tahun YYYY tentang ..." dan, bila tersedia,
|
| 56 |
+
frbr_uri dalam tanda kurung.
|
| 57 |
+
- Jangan mengarang fakta hukum atau angka statistik. Jika tidak yakin, gunakan placeholder
|
| 58 |
+
bersudut kurung siku (mis. [DATA BPS DAPIL]).
|
| 59 |
+
- Output dalam Markdown.
|
| 60 |
+
"""
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def draft(
|
| 64 |
+
pool: LLMPool,
|
| 65 |
+
pasal: PasalClient,
|
| 66 |
+
kind: NaskahKind,
|
| 67 |
+
topic: str,
|
| 68 |
+
*,
|
| 69 |
+
with_research: bool = True,
|
| 70 |
+
extra_instructions: str | None = None,
|
| 71 |
+
console: Console | None = None,
|
| 72 |
+
) -> str:
|
| 73 |
+
console = console or Console()
|
| 74 |
+
style = STYLE_GUIDES.get(kind)
|
| 75 |
+
if not style:
|
| 76 |
+
raise ValueError(f"jenis naskah tidak dikenal: {kind}. Pilihan: {list(STYLE_GUIDES)}")
|
| 77 |
+
|
| 78 |
+
research_block = ""
|
| 79 |
+
if with_research:
|
| 80 |
+
console.print(f"[cyan]penyusun: meminta peneliti merangkum basis hukum '{topic}'[/cyan]")
|
| 81 |
+
memo = peneliti.research(pool, pasal, topic, console=console)
|
| 82 |
+
research_block = f"\n\nBASIS RISET:\n{memo}\n"
|
| 83 |
+
|
| 84 |
+
user_msg = (
|
| 85 |
+
f"Topik: {topic}\n"
|
| 86 |
+
f"Jenis: {kind}\n"
|
| 87 |
+
+ (f"Instruksi tambahan: {extra_instructions}\n" if extra_instructions else "")
|
| 88 |
+
+ research_block
|
| 89 |
+
+ "\nSusun naskah lengkap sesuai pedoman gaya."
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
return pool.big.chat(
|
| 93 |
+
[
|
| 94 |
+
{"role": "system", "content": SYSTEM_TEMPLATE.format(kind=kind, style=style)},
|
| 95 |
+
{"role": "user", "content": user_msg},
|
| 96 |
+
],
|
| 97 |
+
temperature=0.5,
|
| 98 |
+
max_tokens=4096,
|
| 99 |
+
)
|
src/legawa/agents/surat.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Surat Konstituen — triase dan drafting balasan untuk surat/email konstituen.
|
| 2 |
+
|
| 3 |
+
Pipeline:
|
| 4 |
+
1. SMALL model: klasifikasi terstruktur (kategori, urgensi, dapil-relevance, sentimen) +
|
| 5 |
+
ekstraksi entitas (nama, lokasi, isu utama, permintaan konkret).
|
| 6 |
+
2. BIG model: jika user minta draft, susun balasan formal Bahasa Indonesia dengan
|
| 7 |
+
gaya pelayanan publik yang empatik namun realistis tentang kewenangan legislator.
|
| 8 |
+
3. Jika klasifikasi menyebut peraturan/UU spesifik, panggil pasal.id untuk verifikasi
|
| 9 |
+
sebelum drafting (opsional, dikontrol flag).
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import json
|
| 15 |
+
from dataclasses import dataclass
|
| 16 |
+
from typing import Any
|
| 17 |
+
|
| 18 |
+
from pydantic import BaseModel, Field, ValidationError
|
| 19 |
+
from rich.console import Console
|
| 20 |
+
|
| 21 |
+
from ..llm import LLMPool
|
| 22 |
+
from ..tools.pasal import PasalClient
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
KATEGORI = [
|
| 26 |
+
"aspirasi_kebijakan", # usulan revisi UU/peraturan
|
| 27 |
+
"pengaduan_layanan_publik", # listrik, kesehatan, pendidikan, dll
|
| 28 |
+
"permohonan_bantuan", # bantuan sosial, individu
|
| 29 |
+
"undangan_acara",
|
| 30 |
+
"ucapan", # selamat, dukacita, dll
|
| 31 |
+
"lain_lain",
|
| 32 |
+
]
|
| 33 |
+
|
| 34 |
+
URGENSI = ["rendah", "sedang", "tinggi", "kritis"]
|
| 35 |
+
SENTIMEN = ["positif", "netral", "negatif", "marah"]
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class TriaseHasil(BaseModel):
|
| 39 |
+
kategori: str = Field(..., description=f"Salah satu dari: {KATEGORI}")
|
| 40 |
+
urgensi: str = Field(..., description=f"Salah satu dari: {URGENSI}")
|
| 41 |
+
sentimen: str = Field(..., description=f"Salah satu dari: {SENTIMEN}")
|
| 42 |
+
dapil_relevance: bool = Field(..., description="Apakah isu kemungkinan berasal dari/relevan dengan dapil legislator.")
|
| 43 |
+
nama_pengirim: str | None = None
|
| 44 |
+
lokasi: str | None = None
|
| 45 |
+
isu_utama: str = Field(..., description="1 kalimat ringkas inti permasalahan.")
|
| 46 |
+
permintaan_konkret: list[str] = Field(default_factory=list, description="Daftar permintaan eksplisit dari pengirim.")
|
| 47 |
+
peraturan_disebut: list[str] = Field(default_factory=list, description="UU/PP/Perpres yang disebut surat, mis. 'UU 13/2003'.")
|
| 48 |
+
perlu_tindak_lanjut_staf: bool = Field(..., description="Butuh action item untuk staf (cek lapangan, koordinasi dinas, dll).")
|
| 49 |
+
rangkuman: str = Field(..., description="2–3 kalimat rangkuman netral untuk staf.")
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@dataclass
|
| 53 |
+
class SuratResult:
|
| 54 |
+
triase: TriaseHasil
|
| 55 |
+
raw_classification: str
|
| 56 |
+
balasan: str | None = None
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
TRIASE_PROMPT = f"""\
|
| 60 |
+
Anda adalah staf ahli yang melakukan triase surat/email konstituen untuk anggota legislatif Indonesia.
|
| 61 |
+
|
| 62 |
+
Klasifikasikan surat berikut dan ekstrak informasi terstruktur. Output WAJIB berupa JSON valid
|
| 63 |
+
yang sesuai schema berikut (tanpa teks tambahan, tanpa code fence):
|
| 64 |
+
|
| 65 |
+
{{
|
| 66 |
+
"kategori": "<salah satu: {', '.join(KATEGORI)}>",
|
| 67 |
+
"urgensi": "<salah satu: {', '.join(URGENSI)}>",
|
| 68 |
+
"sentimen": "<salah satu: {', '.join(SENTIMEN)}>",
|
| 69 |
+
"dapil_relevance": <true|false>,
|
| 70 |
+
"nama_pengirim": "<string atau null>",
|
| 71 |
+
"lokasi": "<string atau null>",
|
| 72 |
+
"isu_utama": "<1 kalimat>",
|
| 73 |
+
"permintaan_konkret": ["<string>", ...],
|
| 74 |
+
"peraturan_disebut": ["<string>", ...],
|
| 75 |
+
"perlu_tindak_lanjut_staf": <true|false>,
|
| 76 |
+
"rangkuman": "<2-3 kalimat>"
|
| 77 |
+
}}
|
| 78 |
+
|
| 79 |
+
Aturan urgensi:
|
| 80 |
+
- kritis: mengancam jiwa/keselamatan, bencana, kekerasan.
|
| 81 |
+
- tinggi: kerugian materiil signifikan, hak dasar terlanggar, urgensi waktu < 1 minggu.
|
| 82 |
+
- sedang: pengaduan layanan publik standar, aspirasi kebijakan dengan dampak konstituen.
|
| 83 |
+
- rendah: ucapan, undangan rutin, pertanyaan informatif.
|
| 84 |
+
"""
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
REPLY_PROMPT = """\
|
| 88 |
+
Anda adalah staf komunikasi legislator Indonesia. Susun balasan formal Bahasa Indonesia
|
| 89 |
+
untuk surat konstituen berikut.
|
| 90 |
+
|
| 91 |
+
Pedoman:
|
| 92 |
+
- Sapaan formal sesuai jenis pengirim ("Yth. Bapak/Ibu [Nama]" atau "Yth. Saudara/i").
|
| 93 |
+
- Akui penerimaan surat dan empati pada isu yang diangkat (1 paragraf).
|
| 94 |
+
- Jelaskan secara realistis kewenangan legislator: legislator MEMBUAT undang-undang dan
|
| 95 |
+
mengawasi pemerintah, BUKAN eksekusi langsung. Untuk pengaduan layanan publik, arahkan
|
| 96 |
+
ke instansi/lembaga yang berwenang, namun tegaskan akan dilakukan pengawasan/koordinasi.
|
| 97 |
+
- Jika ada peraturan yang relevan dan terverifikasi (lihat bagian "BASIS HUKUM"), sitasi.
|
| 98 |
+
- Tutup dengan komitmen tindak lanjut konkret (mis. teruskan ke komisi terkait, agendakan
|
| 99 |
+
rapat dengar pendapat, dll) — jangan janji yang tidak bisa ditepati.
|
| 100 |
+
- Tanda tangan: "Hormat kami,\\n[NAMA ANGGOTA LEGISLATIF]\\n[FRAKSI/DAERAH PEMILIHAN]"
|
| 101 |
+
- Panjang 250–450 kata. Output Markdown.
|
| 102 |
+
"""
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _parse_triase(raw: str) -> TriaseHasil:
|
| 106 |
+
cleaned = raw.strip()
|
| 107 |
+
if cleaned.startswith("```"):
|
| 108 |
+
cleaned = cleaned.strip("`")
|
| 109 |
+
if cleaned.lower().startswith("json"):
|
| 110 |
+
cleaned = cleaned[4:].strip()
|
| 111 |
+
# Some models prepend explanations; try to isolate the JSON object.
|
| 112 |
+
if not cleaned.startswith("{"):
|
| 113 |
+
start = cleaned.find("{")
|
| 114 |
+
end = cleaned.rfind("}")
|
| 115 |
+
if start != -1 and end != -1 and end > start:
|
| 116 |
+
cleaned = cleaned[start : end + 1]
|
| 117 |
+
data = json.loads(cleaned)
|
| 118 |
+
return TriaseHasil.model_validate(data)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _verify_peraturan(pasal: PasalClient, refs: list[str], console: Console) -> str:
|
| 122 |
+
if not refs:
|
| 123 |
+
return ""
|
| 124 |
+
notes: list[str] = []
|
| 125 |
+
for ref in refs[:5]:
|
| 126 |
+
try:
|
| 127 |
+
r = pasal.search(ref, limit=3)
|
| 128 |
+
hits = r.get("results") or r.get("hits") or []
|
| 129 |
+
if hits:
|
| 130 |
+
top = hits[0]
|
| 131 |
+
title = top.get("title") or top.get("work", {}).get("title") or ref
|
| 132 |
+
frbr = top.get("frbr_uri") or top.get("work", {}).get("frbr_uri") or "-"
|
| 133 |
+
status = top.get("status") or top.get("work", {}).get("status") or "?"
|
| 134 |
+
notes.append(f"- {ref}: {title} (status: {status}, frbr_uri: {frbr})")
|
| 135 |
+
else:
|
| 136 |
+
notes.append(f"- {ref}: TIDAK DITEMUKAN di pasal.id — jangan sitasi.")
|
| 137 |
+
except Exception as e: # noqa: BLE001
|
| 138 |
+
console.print(f"[yellow]surat: gagal verifikasi {ref}: {e}[/yellow]")
|
| 139 |
+
notes.append(f"- {ref}: verifikasi gagal, jangan sitasi.")
|
| 140 |
+
return "BASIS HUKUM:\n" + "\n".join(notes)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def triage(
|
| 144 |
+
pool: LLMPool,
|
| 145 |
+
text: str,
|
| 146 |
+
*,
|
| 147 |
+
console: Console | None = None,
|
| 148 |
+
) -> SuratResult:
|
| 149 |
+
console = console or Console()
|
| 150 |
+
raw = pool.small.chat(
|
| 151 |
+
[
|
| 152 |
+
{"role": "system", "content": TRIASE_PROMPT},
|
| 153 |
+
{"role": "user", "content": text},
|
| 154 |
+
],
|
| 155 |
+
temperature=0.1,
|
| 156 |
+
max_tokens=1024,
|
| 157 |
+
)
|
| 158 |
+
try:
|
| 159 |
+
triase = _parse_triase(raw)
|
| 160 |
+
except (json.JSONDecodeError, ValidationError) as e:
|
| 161 |
+
console.print(f"[red]surat: gagal parse hasil triase: {e}[/red]")
|
| 162 |
+
console.print(f"[dim]raw: {raw[:500]}[/dim]")
|
| 163 |
+
raise
|
| 164 |
+
|
| 165 |
+
return SuratResult(triase=triase, raw_classification=raw)
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def reply(
|
| 169 |
+
pool: LLMPool,
|
| 170 |
+
pasal: PasalClient,
|
| 171 |
+
text: str,
|
| 172 |
+
*,
|
| 173 |
+
verify_law: bool = True,
|
| 174 |
+
console: Console | None = None,
|
| 175 |
+
) -> SuratResult:
|
| 176 |
+
console = console or Console()
|
| 177 |
+
result = triage(pool, text, console=console)
|
| 178 |
+
t = result.triase
|
| 179 |
+
console.print(
|
| 180 |
+
f"[cyan]surat: kategori={t.kategori} urgensi={t.urgensi} sentimen={t.sentimen} "
|
| 181 |
+
f"dapil={t.dapil_relevance} tindak_lanjut={t.perlu_tindak_lanjut_staf}[/cyan]"
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
basis = _verify_peraturan(pasal, t.peraturan_disebut, console) if verify_law else ""
|
| 185 |
+
|
| 186 |
+
user_msg = (
|
| 187 |
+
f"SURAT KONSTITUEN:\n---\n{text}\n---\n\n"
|
| 188 |
+
f"HASIL TRIASE (JSON):\n{result.raw_classification}\n"
|
| 189 |
+
+ (f"\n{basis}\n" if basis else "")
|
| 190 |
+
+ "\nSusun balasan sesuai pedoman."
|
| 191 |
+
)
|
| 192 |
+
result.balasan = pool.big.chat(
|
| 193 |
+
[
|
| 194 |
+
{"role": "system", "content": REPLY_PROMPT},
|
| 195 |
+
{"role": "user", "content": user_msg},
|
| 196 |
+
],
|
| 197 |
+
temperature=0.4,
|
| 198 |
+
max_tokens=2048,
|
| 199 |
+
)
|
| 200 |
+
return result
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def format_report(result: SuratResult) -> str:
|
| 204 |
+
"""Render triase + balasan sebagai satu dokumen Markdown."""
|
| 205 |
+
t = result.triase
|
| 206 |
+
parts = [
|
| 207 |
+
"# Triase Surat Konstituen",
|
| 208 |
+
"",
|
| 209 |
+
f"- **Kategori**: {t.kategori}",
|
| 210 |
+
f"- **Urgensi**: {t.urgensi}",
|
| 211 |
+
f"- **Sentimen**: {t.sentimen}",
|
| 212 |
+
f"- **Relevansi dapil**: {'ya' if t.dapil_relevance else 'tidak'}",
|
| 213 |
+
f"- **Tindak lanjut staf**: {'ya' if t.perlu_tindak_lanjut_staf else 'tidak'}",
|
| 214 |
+
f"- **Pengirim**: {t.nama_pengirim or '-'}",
|
| 215 |
+
f"- **Lokasi**: {t.lokasi or '-'}",
|
| 216 |
+
"",
|
| 217 |
+
f"**Isu utama**: {t.isu_utama}",
|
| 218 |
+
"",
|
| 219 |
+
"**Permintaan konkret**:",
|
| 220 |
+
]
|
| 221 |
+
parts.extend(f"- {p}" for p in (t.permintaan_konkret or ["(tidak ada)"]))
|
| 222 |
+
if t.peraturan_disebut:
|
| 223 |
+
parts += ["", "**Peraturan disebut**:"]
|
| 224 |
+
parts.extend(f"- {p}" for p in t.peraturan_disebut)
|
| 225 |
+
parts += ["", "**Rangkuman untuk staf**:", t.rangkuman]
|
| 226 |
+
if result.balasan:
|
| 227 |
+
parts += ["", "---", "", "# Draf Balasan", "", result.balasan]
|
| 228 |
+
return "\n".join(parts)
|
src/legawa/cli.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""legawa CLI."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
import typer
|
| 9 |
+
from rich.console import Console
|
| 10 |
+
from rich.markdown import Markdown
|
| 11 |
+
|
| 12 |
+
from .agents import analis_ruu, peneliti, penyusun, surat
|
| 13 |
+
from .config import load_settings
|
| 14 |
+
from .llm import LLMPool
|
| 15 |
+
from .tools.cache import CachingPasalClient
|
| 16 |
+
from .tools.pasal import PasalClient
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
app = typer.Typer(
|
| 20 |
+
name="legawa",
|
| 21 |
+
help="Asisten multi-agen untuk legislator Indonesia (DPR/DPRD).",
|
| 22 |
+
no_args_is_help=True,
|
| 23 |
+
add_completion=False,
|
| 24 |
+
)
|
| 25 |
+
console = Console()
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _bootstrap(use_cache: bool = True) -> tuple[LLMPool, PasalClient | CachingPasalClient]:
|
| 29 |
+
settings = load_settings()
|
| 30 |
+
pool = LLMPool(settings)
|
| 31 |
+
raw = PasalClient(settings)
|
| 32 |
+
pasal = CachingPasalClient(raw) if use_cache else raw
|
| 33 |
+
return pool, pasal
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _emit(text: str, out: Optional[Path]) -> None:
|
| 37 |
+
if out:
|
| 38 |
+
out.parent.mkdir(parents=True, exist_ok=True)
|
| 39 |
+
out.write_text(text, encoding="utf-8")
|
| 40 |
+
console.print(f"[green]→ ditulis ke {out}[/green]")
|
| 41 |
+
console.print(Markdown(text))
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@app.command()
|
| 45 |
+
def analyze(
|
| 46 |
+
source: str = typer.Argument(..., help="Path ke RUU (PDF/TXT) atau teks RUU langsung."),
|
| 47 |
+
out: Optional[Path] = typer.Option(None, "--out", "-o", help="Tulis hasil ke file Markdown."),
|
| 48 |
+
) -> None:
|
| 49 |
+
"""Analisis RUU: ringkasan, pasal-per-pasal, konflik dengan UU existing."""
|
| 50 |
+
pool, pasal = _bootstrap()
|
| 51 |
+
try:
|
| 52 |
+
result = analis_ruu.analyze(pool, pasal, source, console=console)
|
| 53 |
+
finally:
|
| 54 |
+
pasal.close()
|
| 55 |
+
_emit(result.output, out)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@app.command()
|
| 59 |
+
def research(
|
| 60 |
+
topic: str = typer.Argument(..., help="Topik riset hukum (Bahasa Indonesia)."),
|
| 61 |
+
out: Optional[Path] = typer.Option(None, "--out", "-o"),
|
| 62 |
+
) -> None:
|
| 63 |
+
"""Peneliti hukum: ekspansi query, pencarian paralel pasal.id, sintesis memo."""
|
| 64 |
+
pool, pasal = _bootstrap()
|
| 65 |
+
try:
|
| 66 |
+
memo = peneliti.research(pool, pasal, topic, console=console)
|
| 67 |
+
finally:
|
| 68 |
+
pasal.close()
|
| 69 |
+
_emit(memo, out)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
@app.command()
|
| 73 |
+
def draft(
|
| 74 |
+
kind: str = typer.Argument(..., help="Jenis: pidato | naskah_akademik | memo_kebijakan | siaran_pers"),
|
| 75 |
+
topic: str = typer.Argument(..., help="Topik / pokok bahasan."),
|
| 76 |
+
no_research: bool = typer.Option(False, "--no-research", help="Lewati basis riset."),
|
| 77 |
+
instruksi: Optional[str] = typer.Option(None, "--instruksi", "-i", help="Instruksi tambahan."),
|
| 78 |
+
out: Optional[Path] = typer.Option(None, "--out", "-o"),
|
| 79 |
+
) -> None:
|
| 80 |
+
"""Penyusun Naskah: pidato, naskah akademik, memo, siaran pers."""
|
| 81 |
+
pool, pasal = _bootstrap()
|
| 82 |
+
try:
|
| 83 |
+
text = penyusun.draft(
|
| 84 |
+
pool,
|
| 85 |
+
pasal,
|
| 86 |
+
kind=kind, # type: ignore[arg-type]
|
| 87 |
+
topic=topic,
|
| 88 |
+
with_research=not no_research,
|
| 89 |
+
extra_instructions=instruksi,
|
| 90 |
+
console=console,
|
| 91 |
+
)
|
| 92 |
+
finally:
|
| 93 |
+
pasal.close()
|
| 94 |
+
_emit(text, out)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
@app.command(name="surat")
|
| 98 |
+
def surat_cmd(
|
| 99 |
+
source: str = typer.Argument(..., help="Path ke file surat (txt/md) atau teks surat langsung."),
|
| 100 |
+
triase_only: bool = typer.Option(False, "--triase-only", help="Hanya triase, tanpa draft balasan."),
|
| 101 |
+
no_verify: bool = typer.Option(False, "--no-verify", help="Lewati verifikasi peraturan ke pasal.id."),
|
| 102 |
+
out: Optional[Path] = typer.Option(None, "--out", "-o"),
|
| 103 |
+
) -> None:
|
| 104 |
+
"""Triase surat konstituen + draft balasan formal."""
|
| 105 |
+
p = Path(source)
|
| 106 |
+
text = p.read_text(encoding="utf-8") if p.exists() and p.is_file() else source
|
| 107 |
+
|
| 108 |
+
pool, pasal = _bootstrap()
|
| 109 |
+
try:
|
| 110 |
+
if triase_only:
|
| 111 |
+
result = surat.triage(pool, text, console=console)
|
| 112 |
+
else:
|
| 113 |
+
result = surat.reply(pool, pasal, text, verify_law=not no_verify, console=console)
|
| 114 |
+
finally:
|
| 115 |
+
pasal.close()
|
| 116 |
+
_emit(surat.format_report(result), out)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
cache_app = typer.Typer(help="Kelola cache pasal.id (SQLite).", no_args_is_help=True)
|
| 120 |
+
app.add_typer(cache_app, name="cache")
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
@cache_app.command("stats")
|
| 124 |
+
def cache_stats() -> None:
|
| 125 |
+
"""Tampilkan jumlah entri & ukuran cache."""
|
| 126 |
+
_, pasal = _bootstrap(use_cache=True)
|
| 127 |
+
try:
|
| 128 |
+
s = pasal.stats() # type: ignore[union-attr]
|
| 129 |
+
finally:
|
| 130 |
+
pasal.close()
|
| 131 |
+
console.print(s)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
@cache_app.command("purge")
|
| 135 |
+
def cache_purge() -> None:
|
| 136 |
+
"""Hapus entri cache yang sudah expired."""
|
| 137 |
+
_, pasal = _bootstrap(use_cache=True)
|
| 138 |
+
try:
|
| 139 |
+
n = pasal.purge_expired() # type: ignore[union-attr]
|
| 140 |
+
finally:
|
| 141 |
+
pasal.close()
|
| 142 |
+
console.print(f"[green]purged {n} expired entries[/green]")
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
@app.command()
|
| 146 |
+
def health() -> None:
|
| 147 |
+
"""Cek koneksi ke pasal.id dan kedua endpoint llama.cpp."""
|
| 148 |
+
settings = load_settings()
|
| 149 |
+
console.print(f"[dim]pasal.id : {settings.pasal_base_url}[/dim]")
|
| 150 |
+
console.print(f"[dim]LLM big : {settings.big.base_url} ({settings.big.model})[/dim]")
|
| 151 |
+
console.print(f"[dim]LLM small : {settings.small.base_url} ({settings.small.model})[/dim]")
|
| 152 |
+
|
| 153 |
+
pool = LLMPool(settings)
|
| 154 |
+
for label, llm in [("big", pool.big), ("small", pool.small)]:
|
| 155 |
+
try:
|
| 156 |
+
reply = llm.chat(
|
| 157 |
+
[{"role": "user", "content": "Jawab dengan satu kata: OK"}],
|
| 158 |
+
max_tokens=8,
|
| 159 |
+
temperature=0.0,
|
| 160 |
+
)
|
| 161 |
+
console.print(f"[green]✓ {label}: {reply.strip()[:40]}[/green]")
|
| 162 |
+
except Exception as e: # noqa: BLE001
|
| 163 |
+
console.print(f"[red]✗ {label}: {e}[/red]")
|
| 164 |
+
|
| 165 |
+
try:
|
| 166 |
+
with PasalClient(settings) as pasal:
|
| 167 |
+
r = pasal.search("ketenagakerjaan", limit=1)
|
| 168 |
+
total = r.get("total") or len(r.get("results", []) or [])
|
| 169 |
+
console.print(f"[green]✓ pasal.id: query 'ketenagakerjaan' → {total} hit[/green]")
|
| 170 |
+
except Exception as e: # noqa: BLE001
|
| 171 |
+
console.print(f"[red]✗ pasal.id: {e}[/red]")
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
if __name__ == "__main__":
|
| 175 |
+
app()
|
src/legawa/config.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
+
|
| 6 |
+
from dotenv import load_dotenv
|
| 7 |
+
|
| 8 |
+
load_dotenv()
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@dataclass(frozen=True)
|
| 12 |
+
class LLMConfig:
|
| 13 |
+
base_url: str
|
| 14 |
+
api_key: str
|
| 15 |
+
model: str
|
| 16 |
+
temperature: float
|
| 17 |
+
max_tokens: int
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@dataclass(frozen=True)
|
| 21 |
+
class Settings:
|
| 22 |
+
pasal_token: str
|
| 23 |
+
pasal_base_url: str
|
| 24 |
+
big: LLMConfig
|
| 25 |
+
small: LLMConfig
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _required(key: str) -> str:
|
| 29 |
+
val = os.environ.get(key)
|
| 30 |
+
if not val:
|
| 31 |
+
raise RuntimeError(f"missing env var: {key}")
|
| 32 |
+
return val
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def load_settings() -> Settings:
|
| 36 |
+
temp = float(os.environ.get("LLM_TEMPERATURE", "0.3"))
|
| 37 |
+
max_tokens = int(os.environ.get("LLM_MAX_TOKENS", "4096"))
|
| 38 |
+
return Settings(
|
| 39 |
+
pasal_token=_required("PASAL_API_TOKEN"),
|
| 40 |
+
pasal_base_url=os.environ.get("PASAL_BASE_URL", "https://pasal.id/api/v1"),
|
| 41 |
+
big=LLMConfig(
|
| 42 |
+
base_url=_required("LLM_BIG_URL"),
|
| 43 |
+
api_key=os.environ.get("LLM_BIG_API_KEY", "sk-no-key-required"),
|
| 44 |
+
model=os.environ.get("LLM_BIG_MODEL", "qwen3"),
|
| 45 |
+
temperature=temp,
|
| 46 |
+
max_tokens=max_tokens,
|
| 47 |
+
),
|
| 48 |
+
small=LLMConfig(
|
| 49 |
+
base_url=_required("LLM_SMALL_URL"),
|
| 50 |
+
api_key=os.environ.get("LLM_SMALL_API_KEY", "sk-no-key-required"),
|
| 51 |
+
model=os.environ.get("LLM_SMALL_MODEL", "qwen3"),
|
| 52 |
+
temperature=temp,
|
| 53 |
+
max_tokens=max_tokens,
|
| 54 |
+
),
|
| 55 |
+
)
|
src/legawa/llm.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LLM client wrappers for the two llama.cpp endpoints.
|
| 2 |
+
|
| 3 |
+
- BIG (localhost:8080, Qwen3 ~35B): synthesis, drafting, deep analysis.
|
| 4 |
+
- SMALL (mi25:8080, Qwen3 ~27B): classification, extraction, query expansion.
|
| 5 |
+
|
| 6 |
+
Both expose an OpenAI-compatible chat/completions API, so we use the openai SDK.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from typing import Any, Iterable
|
| 12 |
+
|
| 13 |
+
from openai import OpenAI
|
| 14 |
+
|
| 15 |
+
from .config import LLMConfig, Settings
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class LLM:
|
| 19 |
+
def __init__(self, cfg: LLMConfig):
|
| 20 |
+
self.cfg = cfg
|
| 21 |
+
self.client = OpenAI(base_url=cfg.base_url, api_key=cfg.api_key)
|
| 22 |
+
|
| 23 |
+
def chat(
|
| 24 |
+
self,
|
| 25 |
+
messages: list[dict[str, Any]],
|
| 26 |
+
*,
|
| 27 |
+
temperature: float | None = None,
|
| 28 |
+
max_tokens: int | None = None,
|
| 29 |
+
) -> str:
|
| 30 |
+
resp = self.client.chat.completions.create(
|
| 31 |
+
model=self.cfg.model,
|
| 32 |
+
messages=messages,
|
| 33 |
+
temperature=self.cfg.temperature if temperature is None else temperature,
|
| 34 |
+
max_tokens=self.cfg.max_tokens if max_tokens is None else max_tokens,
|
| 35 |
+
)
|
| 36 |
+
return resp.choices[0].message.content or ""
|
| 37 |
+
|
| 38 |
+
def chat_with_tools(
|
| 39 |
+
self,
|
| 40 |
+
messages: list[dict[str, Any]],
|
| 41 |
+
tools: Iterable[dict[str, Any]],
|
| 42 |
+
*,
|
| 43 |
+
temperature: float | None = None,
|
| 44 |
+
max_tokens: int | None = None,
|
| 45 |
+
) -> Any:
|
| 46 |
+
"""Single tool-calling round-trip; returns the raw choice.message object."""
|
| 47 |
+
resp = self.client.chat.completions.create(
|
| 48 |
+
model=self.cfg.model,
|
| 49 |
+
messages=messages,
|
| 50 |
+
tools=list(tools),
|
| 51 |
+
tool_choice="auto",
|
| 52 |
+
temperature=self.cfg.temperature if temperature is None else temperature,
|
| 53 |
+
max_tokens=self.cfg.max_tokens if max_tokens is None else max_tokens,
|
| 54 |
+
)
|
| 55 |
+
return resp.choices[0].message
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class LLMPool:
|
| 59 |
+
def __init__(self, settings: Settings):
|
| 60 |
+
self.big = LLM(settings.big)
|
| 61 |
+
self.small = LLM(settings.small)
|
src/legawa/tools/__init__.py
ADDED
|
File without changes
|
src/legawa/tools/cache.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SQLite cache layer for the pasal.id REST client.
|
| 2 |
+
|
| 3 |
+
Transparent wrapper over PasalClient. Same method names, same return shapes.
|
| 4 |
+
Agents and the CLI swap PasalClient → CachingPasalClient with no other changes.
|
| 5 |
+
|
| 6 |
+
TTL defaults:
|
| 7 |
+
search : 3600 (1 hour) — search results shift as the corpus grows
|
| 8 |
+
list_laws : 3600 (1 hour)
|
| 9 |
+
get_law : 86400 (24 hours) — full document content is stable
|
| 10 |
+
|
| 11 |
+
Cache file:
|
| 12 |
+
$LEGAWA_CACHE_PATH if set, else $HOME/.legawa/cache.db
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import json
|
| 18 |
+
import os
|
| 19 |
+
import sqlite3
|
| 20 |
+
import time
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
from typing import Any, Callable
|
| 23 |
+
|
| 24 |
+
from .pasal import PasalClient
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
DEFAULT_TTL_SEARCH = 3600
|
| 28 |
+
DEFAULT_TTL_LIST = 3600
|
| 29 |
+
DEFAULT_TTL_GET = 86400
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _default_cache_path() -> Path:
|
| 33 |
+
override = os.environ.get("LEGAWA_CACHE_PATH")
|
| 34 |
+
if override:
|
| 35 |
+
return Path(override)
|
| 36 |
+
return Path.home() / ".legawa" / "cache.db"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class _SqliteCache:
|
| 40 |
+
def __init__(self, path: Path):
|
| 41 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 42 |
+
self.conn = sqlite3.connect(str(path), isolation_level=None, check_same_thread=False)
|
| 43 |
+
self.conn.execute("PRAGMA journal_mode=WAL")
|
| 44 |
+
self.conn.execute(
|
| 45 |
+
"CREATE TABLE IF NOT EXISTS cache ("
|
| 46 |
+
" key TEXT PRIMARY KEY,"
|
| 47 |
+
" value TEXT NOT NULL,"
|
| 48 |
+
" expires_at INTEGER NOT NULL"
|
| 49 |
+
")"
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
def get(self, key: str) -> Any | None:
|
| 53 |
+
now = int(time.time())
|
| 54 |
+
row = self.conn.execute(
|
| 55 |
+
"SELECT value, expires_at FROM cache WHERE key = ?", (key,)
|
| 56 |
+
).fetchone()
|
| 57 |
+
if row is None:
|
| 58 |
+
return None
|
| 59 |
+
value, expires_at = row
|
| 60 |
+
if expires_at < now:
|
| 61 |
+
self.conn.execute("DELETE FROM cache WHERE key = ?", (key,))
|
| 62 |
+
return None
|
| 63 |
+
return json.loads(value)
|
| 64 |
+
|
| 65 |
+
def set(self, key: str, value: Any, ttl: int) -> None:
|
| 66 |
+
expires_at = int(time.time()) + ttl
|
| 67 |
+
self.conn.execute(
|
| 68 |
+
"INSERT OR REPLACE INTO cache(key, value, expires_at) VALUES (?, ?, ?)",
|
| 69 |
+
(key, json.dumps(value, ensure_ascii=False), expires_at),
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
def purge_expired(self) -> int:
|
| 73 |
+
cur = self.conn.execute("DELETE FROM cache WHERE expires_at < ?", (int(time.time()),))
|
| 74 |
+
return cur.rowcount or 0
|
| 75 |
+
|
| 76 |
+
def stats(self) -> dict[str, int]:
|
| 77 |
+
rows = self.conn.execute(
|
| 78 |
+
"SELECT COUNT(*), COALESCE(SUM(LENGTH(value)), 0) FROM cache"
|
| 79 |
+
).fetchone()
|
| 80 |
+
return {"entries": rows[0], "bytes": rows[1]}
|
| 81 |
+
|
| 82 |
+
def close(self) -> None:
|
| 83 |
+
self.conn.close()
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _key(prefix: str, args: dict[str, Any]) -> str:
|
| 87 |
+
# Drop None values so equivalent calls collapse to the same key.
|
| 88 |
+
cleaned = {k: v for k, v in args.items() if v is not None}
|
| 89 |
+
return f"{prefix}::{json.dumps(cleaned, sort_keys=True, ensure_ascii=False)}"
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class CachingPasalClient:
|
| 93 |
+
"""Drop-in replacement for PasalClient with on-disk caching."""
|
| 94 |
+
|
| 95 |
+
def __init__(
|
| 96 |
+
self,
|
| 97 |
+
inner: PasalClient,
|
| 98 |
+
cache_path: Path | None = None,
|
| 99 |
+
*,
|
| 100 |
+
ttl_search: int = DEFAULT_TTL_SEARCH,
|
| 101 |
+
ttl_list: int = DEFAULT_TTL_LIST,
|
| 102 |
+
ttl_get: int = DEFAULT_TTL_GET,
|
| 103 |
+
):
|
| 104 |
+
self._inner = inner
|
| 105 |
+
self._cache = _SqliteCache(cache_path or _default_cache_path())
|
| 106 |
+
self._ttl_search = ttl_search
|
| 107 |
+
self._ttl_list = ttl_list
|
| 108 |
+
self._ttl_get = ttl_get
|
| 109 |
+
self.hits = 0
|
| 110 |
+
self.misses = 0
|
| 111 |
+
|
| 112 |
+
def _cached(self, prefix: str, args: dict[str, Any], ttl: int, fetch: Callable[[], Any]) -> Any:
|
| 113 |
+
key = _key(prefix, args)
|
| 114 |
+
hit = self._cache.get(key)
|
| 115 |
+
if hit is not None:
|
| 116 |
+
self.hits += 1
|
| 117 |
+
return hit
|
| 118 |
+
self.misses += 1
|
| 119 |
+
value = fetch()
|
| 120 |
+
self._cache.set(key, value, ttl)
|
| 121 |
+
return value
|
| 122 |
+
|
| 123 |
+
def search(self, q: str, type: str | None = None, limit: int = 10) -> dict[str, Any]:
|
| 124 |
+
args = {"q": q, "type": type, "limit": limit}
|
| 125 |
+
return self._cached("search", args, self._ttl_search, lambda: self._inner.search(**args))
|
| 126 |
+
|
| 127 |
+
def list_laws(
|
| 128 |
+
self,
|
| 129 |
+
type: str | None = None,
|
| 130 |
+
year: int | None = None,
|
| 131 |
+
status: str | None = None,
|
| 132 |
+
limit: int = 20,
|
| 133 |
+
offset: int = 0,
|
| 134 |
+
) -> dict[str, Any]:
|
| 135 |
+
args = {"type": type, "year": year, "status": status, "limit": limit, "offset": offset}
|
| 136 |
+
return self._cached("list", args, self._ttl_list, lambda: self._inner.list_laws(**args))
|
| 137 |
+
|
| 138 |
+
def get_law(self, frbr_uri: str) -> dict[str, Any]:
|
| 139 |
+
args = {"frbr_uri": frbr_uri}
|
| 140 |
+
return self._cached("get", args, self._ttl_get, lambda: self._inner.get_law(**args))
|
| 141 |
+
|
| 142 |
+
def stats(self) -> dict[str, Any]:
|
| 143 |
+
return {**self._cache.stats(), "session_hits": self.hits, "session_misses": self.misses}
|
| 144 |
+
|
| 145 |
+
def purge_expired(self) -> int:
|
| 146 |
+
return self._cache.purge_expired()
|
| 147 |
+
|
| 148 |
+
def close(self) -> None:
|
| 149 |
+
try:
|
| 150 |
+
self._inner.close()
|
| 151 |
+
finally:
|
| 152 |
+
self._cache.close()
|
| 153 |
+
|
| 154 |
+
def __enter__(self) -> "CachingPasalClient":
|
| 155 |
+
return self
|
| 156 |
+
|
| 157 |
+
def __exit__(self, *_: Any) -> None:
|
| 158 |
+
self.close()
|
src/legawa/tools/pasal.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Client for the pasal.id REST API.
|
| 2 |
+
|
| 3 |
+
Endpoints (https://pasal.id/api/v1):
|
| 4 |
+
GET /search?q=...&type=...&limit=... — keyword search
|
| 5 |
+
GET /laws?type=...&year=...&status=... — list with filters
|
| 6 |
+
GET /laws/{frbr_uri} — full document + articles
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
import httpx
|
| 14 |
+
|
| 15 |
+
from ..config import Settings
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
REGULATION_TYPES = [
|
| 19 |
+
"UU", "PERPPU", "PP", "PERPRES", "PERMEN",
|
| 20 |
+
"PERDA", "PERDA_PROV", "PERDA_KAB",
|
| 21 |
+
]
|
| 22 |
+
|
| 23 |
+
STATUS_VALUES = ["berlaku", "dicabut", "diubah"]
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class PasalClient:
|
| 27 |
+
def __init__(self, settings: Settings, timeout: float = 30.0):
|
| 28 |
+
self._client = httpx.Client(
|
| 29 |
+
base_url=settings.pasal_base_url,
|
| 30 |
+
headers={
|
| 31 |
+
"Authorization": f"Bearer {settings.pasal_token}",
|
| 32 |
+
"Accept": "application/json",
|
| 33 |
+
"User-Agent": "legawa/0.1",
|
| 34 |
+
},
|
| 35 |
+
timeout=timeout,
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
def close(self) -> None:
|
| 39 |
+
self._client.close()
|
| 40 |
+
|
| 41 |
+
def __enter__(self) -> "PasalClient":
|
| 42 |
+
return self
|
| 43 |
+
|
| 44 |
+
def __exit__(self, *_: Any) -> None:
|
| 45 |
+
self.close()
|
| 46 |
+
|
| 47 |
+
def search(
|
| 48 |
+
self,
|
| 49 |
+
q: str,
|
| 50 |
+
type: str | None = None,
|
| 51 |
+
limit: int = 10,
|
| 52 |
+
) -> dict[str, Any]:
|
| 53 |
+
params: dict[str, Any] = {"q": q, "limit": max(1, min(20, limit))}
|
| 54 |
+
if type:
|
| 55 |
+
params["type"] = type
|
| 56 |
+
r = self._client.get("/search", params=params)
|
| 57 |
+
r.raise_for_status()
|
| 58 |
+
return r.json()
|
| 59 |
+
|
| 60 |
+
def list_laws(
|
| 61 |
+
self,
|
| 62 |
+
type: str | None = None,
|
| 63 |
+
year: int | None = None,
|
| 64 |
+
status: str | None = None,
|
| 65 |
+
limit: int = 20,
|
| 66 |
+
offset: int = 0,
|
| 67 |
+
) -> dict[str, Any]:
|
| 68 |
+
params: dict[str, Any] = {
|
| 69 |
+
"limit": max(1, min(50, limit)),
|
| 70 |
+
"offset": max(0, offset),
|
| 71 |
+
}
|
| 72 |
+
if type:
|
| 73 |
+
params["type"] = type
|
| 74 |
+
if year:
|
| 75 |
+
params["year"] = year
|
| 76 |
+
if status:
|
| 77 |
+
params["status"] = status
|
| 78 |
+
r = self._client.get("/laws", params=params)
|
| 79 |
+
r.raise_for_status()
|
| 80 |
+
return r.json()
|
| 81 |
+
|
| 82 |
+
def get_law(self, frbr_uri: str) -> dict[str, Any]:
|
| 83 |
+
# frbr_uri example: akn/id/act/uu/2003/13
|
| 84 |
+
r = self._client.get(f"/laws/{frbr_uri.lstrip('/')}")
|
| 85 |
+
r.raise_for_status()
|
| 86 |
+
return r.json()
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
# OpenAI-compatible tool schemas exposed to agents.
|
| 90 |
+
TOOL_SCHEMAS: list[dict[str, Any]] = [
|
| 91 |
+
{
|
| 92 |
+
"type": "function",
|
| 93 |
+
"function": {
|
| 94 |
+
"name": "pasal_search",
|
| 95 |
+
"description": (
|
| 96 |
+
"Cari peraturan perundang-undangan Indonesia di pasal.id berdasarkan kata kunci. "
|
| 97 |
+
"Mengembalikan daftar peraturan beserta cuplikan pasal yang relevan dan frbr_uri "
|
| 98 |
+
"yang bisa dipakai untuk mengambil dokumen lengkap."
|
| 99 |
+
),
|
| 100 |
+
"parameters": {
|
| 101 |
+
"type": "object",
|
| 102 |
+
"properties": {
|
| 103 |
+
"q": {"type": "string", "description": "Kata kunci pencarian (Bahasa Indonesia)."},
|
| 104 |
+
"type": {
|
| 105 |
+
"type": "string",
|
| 106 |
+
"enum": REGULATION_TYPES,
|
| 107 |
+
"description": "Filter jenis peraturan (opsional).",
|
| 108 |
+
},
|
| 109 |
+
"limit": {
|
| 110 |
+
"type": "integer",
|
| 111 |
+
"minimum": 1,
|
| 112 |
+
"maximum": 20,
|
| 113 |
+
"default": 10,
|
| 114 |
+
},
|
| 115 |
+
},
|
| 116 |
+
"required": ["q"],
|
| 117 |
+
},
|
| 118 |
+
},
|
| 119 |
+
},
|
| 120 |
+
{
|
| 121 |
+
"type": "function",
|
| 122 |
+
"function": {
|
| 123 |
+
"name": "pasal_list_laws",
|
| 124 |
+
"description": (
|
| 125 |
+
"Daftar peraturan dengan filter jenis, tahun, dan status. Berguna untuk eksplorasi "
|
| 126 |
+
"tanpa kata kunci spesifik."
|
| 127 |
+
),
|
| 128 |
+
"parameters": {
|
| 129 |
+
"type": "object",
|
| 130 |
+
"properties": {
|
| 131 |
+
"type": {"type": "string", "enum": REGULATION_TYPES},
|
| 132 |
+
"year": {"type": "integer", "minimum": 1945, "maximum": 2100},
|
| 133 |
+
"status": {"type": "string", "enum": STATUS_VALUES},
|
| 134 |
+
"limit": {"type": "integer", "minimum": 1, "maximum": 50, "default": 20},
|
| 135 |
+
"offset": {"type": "integer", "minimum": 0, "default": 0},
|
| 136 |
+
},
|
| 137 |
+
},
|
| 138 |
+
},
|
| 139 |
+
},
|
| 140 |
+
{
|
| 141 |
+
"type": "function",
|
| 142 |
+
"function": {
|
| 143 |
+
"name": "pasal_get_law",
|
| 144 |
+
"description": (
|
| 145 |
+
"Ambil dokumen peraturan lengkap beserta seluruh pasal dan relasi antar peraturan, "
|
| 146 |
+
"berdasarkan frbr_uri (mis. 'akn/id/act/uu/2003/13')."
|
| 147 |
+
),
|
| 148 |
+
"parameters": {
|
| 149 |
+
"type": "object",
|
| 150 |
+
"properties": {
|
| 151 |
+
"frbr_uri": {
|
| 152 |
+
"type": "string",
|
| 153 |
+
"description": "FRBR URI dari hasil search atau list_laws.",
|
| 154 |
+
},
|
| 155 |
+
},
|
| 156 |
+
"required": ["frbr_uri"],
|
| 157 |
+
},
|
| 158 |
+
},
|
| 159 |
+
},
|
| 160 |
+
]
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def build_dispatcher(client: PasalClient):
|
| 164 |
+
"""Return a callable mapping tool_name -> args -> result dict."""
|
| 165 |
+
|
| 166 |
+
def dispatch(name: str, args: dict[str, Any]) -> Any:
|
| 167 |
+
if name == "pasal_search":
|
| 168 |
+
return client.search(**args)
|
| 169 |
+
if name == "pasal_list_laws":
|
| 170 |
+
return client.list_laws(**args)
|
| 171 |
+
if name == "pasal_get_law":
|
| 172 |
+
return client.get_law(**args)
|
| 173 |
+
raise ValueError(f"unknown tool: {name}")
|
| 174 |
+
|
| 175 |
+
return dispatch
|