pebaryan Claude Opus 4.7 commited on
Commit
3612cad
·
0 Parent(s):

Initial commit: legawa multi-agent system for Indonesian legislators

Browse files

Four 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 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