vovkes222 Claude Opus 4.6 commited on
Commit
a992ecb
·
0 Parent(s):

feat: initial LegalIntern multi-agent consultation system

Browse files

Nine-agent pipeline for complex legal consultations over Ukrainian
court decisions, inspired by HuggingFace PhysicsIntern architecture.

Agents: Surveyor, Planner, Orchestrator, Researcher, Analyst,
Reviewer, Critic, Adjudicator, Formatter.

Includes Gradio app for HF Space demo, SecondLayer MCP bridge,
structured ConsultationState, and example legal problems.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

.gitignore ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .eggs/
7
+ *.egg
8
+ .env
9
+ .venv/
10
+ venv/
11
+ workspaces/
12
+ *.pyc
13
+ .ruff_cache/
14
+ .pytest_cache/
15
+ .coverage
16
+ htmlcov/
17
+ uv.lock
README.md ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: LegalIntern
3
+ emoji: ⚖️
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: gradio
7
+ sdk_version: 5.31.0
8
+ app_file: app.py
9
+ pinned: true
10
+ license: apache-2.0
11
+ tags:
12
+ - legal-nlp
13
+ - multi-agent
14
+ - ukraine
15
+ - court-decisions
16
+ - legal-consultation
17
+ ---
18
+
19
+ # LegalIntern
20
+
21
+ A multi-agent scaffolding system for **complex legal consultations** over Ukrainian court decisions and legislation.
22
+
23
+ Inspired by [PhysicsIntern](https://huggingface.co/spaces/huggingface/physics-intern)'s multi-agent research pipeline, adapted for the legal domain with access to 100M+ Ukrainian court decisions via [SecondLayer](https://legal.org.ua).
24
+
25
+ ## Architecture
26
+
27
+ Nine specialised LLM agents work in a loop, each starting from a fresh context (no conversation history). All state lives in a structured `ConsultationState` object. The workspace is git-versioned for full reproducibility.
28
+
29
+ ```
30
+ ┌─────────────┐
31
+ │ Client │
32
+ │ Question │
33
+ └──────┬──────┘
34
+
35
+ ┌──────▼──────┐
36
+ │ Surveyor │ Maps legal landscape (once)
37
+ └──────┬──────┘
38
+
39
+ ┌──────▼──────┐
40
+ │ Planner │ Research strategy
41
+ └──────┬──────┘
42
+
43
+ ┌────────────▼────────────┐
44
+ │ Orchestrator │ Dispatches tasks
45
+ └───┬─────────────────┬───┘
46
+ │ │
47
+ ┌──────▼──────┐ ┌──────▼──────┐
48
+ │ Researcher │ │ Analyst │
49
+ │ (case law, │ │ (deadlines, │
50
+ │ legislation)│ │ penalties) │
51
+ └──────┬──────┘ └──────┬──────┘
52
+ │ │
53
+ ┌───▼─────────────────▼───┐
54
+ │ Reviewer │ Adversarial verification
55
+ └────────────┬────────────┘
56
+
57
+ ┌────────────▼────────────┐
58
+ │ Critic (periodic) │ Strategy audit
59
+ └──────┬──────────┬───────┘
60
+ │ │
61
+ ┌──────▼──┐ ┌────▼────────┐
62
+ │ Planner │ │ Adjudicator │ Resolve disputes
63
+ │(revise) │ └─────────────┘
64
+ └─────────┘
65
+
66
+ ┌────────────▼────────────┐
67
+ │ Formatter │ Final consultation
68
+ └─────────────────────────┘
69
+ ```
70
+
71
+ ### Agent Roles
72
+
73
+ | Agent | Role | secondlayer-core Equivalent |
74
+ |-------|------|---------------------------|
75
+ | **Surveyor** | Maps legal landscape, identifies relevant areas of law | IntentClassifier + QueryPlanner |
76
+ | **Planner** | Produces/revises research strategy | ExecutionPlan generation |
77
+ | **Orchestrator** | Dispatches tasks, formulates hypotheses | ChatService agentic loop |
78
+ | **Researcher** | Finds case law, legislation, doctrine via SecondLayer MCP | Tool calls (search, legislation) |
79
+ | **Analyst** | Computes deadlines, penalties, procedural checks | Calculation tool calls |
80
+ | **Reviewer** | Adversarial verification of evidence | CitationValidator + HallucinationGuard |
81
+ | **Critic** | Periodic strategy and coherence audit | *New capability* |
82
+ | **Adjudicator** | Resolves inter-agent disagreements | *New capability* |
83
+ | **Formatter** | Produces structured legal consultation | Response synthesis |
84
+
85
+ ### Key Design Decisions
86
+
87
+ 1. **No agent carries conversation history** -- each call starts from a fresh context with the current `ConsultationState` rendered as text. This prevents context contamination and allows any agent to be swapped or retried independently.
88
+
89
+ 2. **Structured state** -- `ConsultationState` tracks hypotheses, evidence, critiques, and their relationships. Agents mutate state via typed operations, not free-form text.
90
+
91
+ 3. **Git-versioned workspace** -- every iteration creates a commit, making the full research process reproducible and auditable.
92
+
93
+ 4. **SecondLayer MCP bridge** -- agents access 100M+ court decisions, legislation, and Supreme Court positions through the SecondLayer API.
94
+
95
+ ## Quick Start
96
+
97
+ ```bash
98
+ # Install
99
+ pip install -e .
100
+
101
+ # Set API keys
102
+ export ANTHROPIC_API_KEY=sk-...
103
+ export SECONDLAYER_API_KEY=...
104
+
105
+ # Run a consultation
106
+ legal-intern "Чи може продавець стягнути пеню за прострочення оплати товару?"
107
+
108
+ # Run from a problem file
109
+ legal-intern problems/consumer_penalty.yaml
110
+ ```
111
+
112
+ ## Example Problems
113
+
114
+ | Problem | Difficulty | Description |
115
+ |---------|-----------|-------------|
116
+ | `consumer_penalty.yaml` | Medium | Penalty and interest calculation for late payment |
117
+ | `labor_dismissal.yaml` | Hard | Challenging unlawful dismissal during martial law |
118
+ | `property_dispute.yaml` | Hard | Property rights in unregistered partnership |
119
+
120
+ ## Data Sources
121
+
122
+ - [EDRSR](https://reyestr.court.gov.ua/) -- 100M+ Ukrainian court decisions
123
+ - [Verkhovna Rada](https://zakon.rada.gov.ua/) -- Ukrainian legislation
124
+ - [overthelex/ua-case-outcome-6m](https://huggingface.co/datasets/overthelex/ua-case-outcome-6m) -- 6.7M court decisions dataset
125
+ - [overthelex/ukrainian-court-decisions](https://huggingface.co/datasets/overthelex/ukrainian-court-decisions) -- 428K balanced benchmark
126
+
127
+ ## Related Papers
128
+
129
+ - [Temporal Decay of Co-Citation Predictability](https://arxiv.org/abs/2605.17639)
130
+ - [A Citation Graph from 100M Court Decisions](https://arxiv.org/abs/2605.15362)
131
+ - [Tokenizer Fertility and Zero-Shot Performance on Ukrainian Legal Text](https://arxiv.org/abs/2605.14890)
132
+
133
+ ## License
134
+
135
+ Apache-2.0
app.py ADDED
@@ -0,0 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gradio app for the LegalIntern HuggingFace Space.
2
+
3
+ Interactive demo of the multi-agent legal consultation pipeline.
4
+ Shows the agent flow, state evolution, and final consultation.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from dataclasses import asdict
11
+
12
+ import gradio as gr
13
+
14
+ from src.legal_intern.state.research_state import (
15
+ ConsultationState,
16
+ LegalEvidence,
17
+ LegalHypothesis,
18
+ LegalStrategy,
19
+ )
20
+ from src.legal_intern.rendering import render_state_md
21
+
22
+
23
+ EXAMPLE_QUESTIONS = [
24
+ "Покупець не оплатив товар на 150 000 грн протягом 6 місяців. Як стягнути пеню, 3% річних та інфляційні?",
25
+ "Працівника звільнено під час воєнного стану без попередження. Чи є підстави для поновлення?",
26
+ "Чи може жінка претендувати на частку квартири після 8 років цивільного шлюбу?",
27
+ "Забудовник затримує введення будинку в експлуатацію на 2 роки. Які компенсації можна вимагати?",
28
+ ]
29
+
30
+ ARCHITECTURE_MD = """
31
+ ## Архітектура LegalIntern
32
+
33
+ Дев'ять спеціалізованих LLM-агентів працюють у циклі. Кожен агент починає з чистого контексту
34
+ (без історії розмови). Весь стан зберігається у структурованому об'єкті `ConsultationState`.
35
+
36
+ ### Pipeline
37
+
38
+ ```
39
+ Запит клієнта
40
+
41
+
42
+ ┌─────────────┐
43
+ │ Surveyor │ Огляд правового ландшафту
44
+ └──────┬──────┘
45
+
46
+ ┌─────────────┐
47
+ │ Planner │ Стратегія дослідження
48
+ └──────┬──────┘
49
+
50
+ ┌─────────────┐ ┌────────────┐
51
+ │Orchestrator │────►│ Researcher │ Судова практика, НПА
52
+ │ (dispatch) │────►│ Analyst │ Обчислення, строки
53
+ └──────┬──────┘ └─────┬──────┘
54
+ │ │
55
+ ▼ ▼
56
+ ┌─────────────┐ ┌────────────┐
57
+ │ Reviewer │ │ Critic │ Аудит стратегії
58
+ │(верифікація)│ │ (periodic) │
59
+ └──────┬──────┘ └─────┬──────┘
60
+ │ │
61
+ ▼ ▼
62
+ ┌─────────────┐ ┌──────────────┐
63
+ │ Adjudicator │ │ Planner │
64
+ │ (арбітраж) │ │ (revision) │
65
+ └──────┬──────┘ └──────────────┘
66
+
67
+ ┌─────────────┐
68
+ │ Formatter │ Фінальна консультація
69
+ └─────────────┘
70
+ ```
71
+
72
+ ### Агенти
73
+
74
+ | Агент | Роль | Аналог в secondlayer-core |
75
+ |-------|------|--------------------------|
76
+ | **Surveyor** | Карта правового ландшафту | IntentClassifier + QueryPlanner |
77
+ | **Planner** | Стратегія дослідження | Генерація ExecutionPlan |
78
+ | **Orchestrator** | Координація та гіпотези | Агентний цикл ChatService |
79
+ | **Researcher** | Пошук практики та НПА | Виклики інструментів |
80
+ | **Analyst** | Обчислення строків, сум | Калькуляційні інструменти |
81
+ | **Reviewer** | Верифікація доказів | CitationValidator + HallucinationGuard |
82
+ | **Critic** | Аудит стратегії | *Нова можливість* |
83
+ | **Adjudicator** | Арбітраж розбіжностей | *Нова можливість* |
84
+ | **Formatter** | Оформлення консультації | Синтез відповіді |
85
+
86
+ ### Ключові принципи
87
+
88
+ 1. **Жоден агент не несе історію** -- кожен виклик починає з чистого контексту
89
+ 2. **Структурований стан** -- гіпотези, докази, зауваження зі зв'язками між ними
90
+ 3. **Git-версіонування** -- кожна ітерація = коміт, повна відтворюваність
91
+ 4. **SecondLayer MCP bridge** -- доступ до 100М+ рішень суду, законодавства, позицій ВС
92
+
93
+ ### Порівняння з PhysicsIntern
94
+
95
+ | Аспект | PhysicsIntern | LegalIntern |
96
+ |--------|--------------|-------------|
97
+ | Домен | Теоретична фізика | Українське право |
98
+ | Researcher | Аналітичні міркування | Пошук у ЄДРСР + НПА |
99
+ | Computer | Виконання Python коду | Обчислення пені, строків |
100
+ | Верифікація | Formal evaluation | Перевірка посилань |
101
+ | Стан | ResearchState (hypotheses) | ConsultationState (правові позиції) |
102
+ | Вихід | ANSWER.md | CONSULTATION.md |
103
+ """
104
+
105
+ DEMO_STATE = ConsultationState(
106
+ client_question="Покупець не оплатив товар на 150 000 грн протягом 6 місяців. Як стягнути пеню?",
107
+ jurisdiction="civil",
108
+ title="Стягнення пені за прострочення оплати",
109
+ survey_summary="Питання стосується цивільно-правової відповідальності за порушення грошового зобов'язання...",
110
+ strategy=LegalStrategy(
111
+ approach="Стягнення на підставі ст. 549-552 ЦК (пеня), ст. 625 ЦК (3% річних + інфляційні)",
112
+ legal_domains=["цивільне право", "зобов'язальне право"],
113
+ key_questions=[
114
+ "Чи передбачена пеня договором?",
115
+ "Який розмір 3% річних та інфляційних?",
116
+ "Яка позиція ВС щодо одночасного стягнення?",
117
+ ],
118
+ relevant_legislation=["ст. 549-552 ЦК України", "ст. 625 ЦК України", "ст. 3 ЗУ 'Про відповідальність за несвоєчасне виконання грошових зобов'язань'"],
119
+ ),
120
+ iteration=5,
121
+ )
122
+ DEMO_STATE._hyp_counter = 2
123
+ DEMO_STATE.hypotheses = [
124
+ LegalHypothesis(
125
+ id="H-001",
126
+ statement="Продавець має право на пеню за ст. 549 ЦК якщо це передбачено договором",
127
+ status="established",
128
+ supporting_evidence=["EV-001", "EV-003"],
129
+ ),
130
+ LegalHypothesis(
131
+ id="H-002",
132
+ statement="3% річних та інфляційні нараховуються незалежно від пені (ст. 625 ЦК)",
133
+ status="established",
134
+ supporting_evidence=["EV-002", "EV-004"],
135
+ ),
136
+ ]
137
+ DEMO_STATE._ev_counter = 4
138
+ DEMO_STATE.evidence = [
139
+ LegalEvidence(id="EV-001", type="legislation", source="rada", citation="ст. 549 ЦК України", summary="Неустойкою (штрафом, пенею) є грошова сума, яку боржник повинен передати кредиторові у разі порушення зобов'язання", confidence="high"),
140
+ LegalEvidence(id="EV-002", type="legislation", source="rada", citation="ст. 625 ЦК України", summary="Боржник зобов'язаний сплатити 3% річних та відшкодувати інфляційні втрати", confidence="high"),
141
+ LegalEvidence(id="EV-003", type="case_law", source="edrsr", citation="Справа №757/12345/22", summary="ВС: пеня нараховується з дня, наступного за останнім днем строку оплати", confidence="high"),
142
+ LegalEvidence(id="EV-004", type="case_law", source="edrsr", citation="ВП ВС, справа №910/5678/21", summary="Одночасне стягнення пені та 3% річних є правомірним, оскільки вони мають різну правову природу", confidence="high"),
143
+ ]
144
+
145
+
146
+ def show_architecture():
147
+ return ARCHITECTURE_MD
148
+
149
+
150
+ def show_demo_state():
151
+ return render_state_md(DEMO_STATE)
152
+
153
+
154
+ def show_demo_json():
155
+ return json.dumps(DEMO_STATE.to_dict(), ensure_ascii=False, indent=2)
156
+
157
+
158
+ def run_demo_consultation(question: str, progress=gr.Progress()):
159
+ """Simulate a consultation run (demo mode without real LLM calls)."""
160
+ if not question.strip():
161
+ return "Будь ласка, введіть правове питання.", "", ""
162
+
163
+ progress(0.1, desc="Surveyor: огляд правового ландшафту...")
164
+ progress(0.25, desc="Planner: розробка стратегії...")
165
+ progress(0.4, desc="Researcher: пошук судової практики...")
166
+ progress(0.55, desc="Analyst: обчислення сум...")
167
+ progress(0.7, desc="Reviewer: верифікація доказів...")
168
+ progress(0.85, desc="Critic: аудит стратегії...")
169
+ progress(0.95, desc="Formatter: оформлення консультації...")
170
+
171
+ demo_answer = f"""# ПРАВОВА КОНСУЛЬТАЦІЯ
172
+
173
+ ## 1. Питання клієнта
174
+ {question}
175
+
176
+ ## 2. Правовий аналіз
177
+
178
+ > Це демо-режим. У повній версії LegalIntern виконує реальний пошук
179
+ > по 100М+ рішень суду в ЄДРСР та аналізує чинне законодавст��о.
180
+
181
+ ### 2.1. Застосовне законодавство
182
+ - Цивільний кодекс України (ст. 549-552, 625)
183
+ - Закон України "Про відповідальність за несвоєчасне виконання грошових зобов'язань"
184
+
185
+ ### 2.2. Судова практика
186
+ - Позиція Верховного Суду щодо одночасного стягнення пені та 3% річних
187
+
188
+ ## 3. Висновок
189
+ Для отримання повної консультації з реальними посиланнями на судову практику
190
+ та обчисленнями, запустіть LegalIntern з API ключами SecondLayer та Anthropic.
191
+
192
+ ## 4. Рекомендації
193
+ 1. Встановіть `ANTHROPIC_API_KEY` та `SECONDLAYER_API_KEY`
194
+ 2. Запустіть: `legal-intern "{question[:50]}..."`
195
+
196
+ ---
197
+ *Demo mode -- real consultations require API access to SecondLayer (legal.org.ua)*
198
+ """
199
+
200
+ state_md = f"""# Стан консультації (демо)
201
+ - Питання: {question[:80]}...
202
+ - Ітерацій: 5 (демо)
203
+ - Гіпотез: 2 (встановлено: 2)
204
+ - Доказів: 4 (спростовано: 0)
205
+ """
206
+
207
+ return demo_answer, state_md, "Demo completed"
208
+
209
+
210
+ # Build Gradio UI
211
+ with gr.Blocks(
212
+ title="LegalIntern -- Multi-Agent Legal Consultation",
213
+ theme=gr.themes.Soft(),
214
+ css="""
215
+ .main-header { text-align: center; margin-bottom: 1rem; }
216
+ .agent-flow { font-family: monospace; }
217
+ """,
218
+ ) as demo:
219
+ gr.Markdown(
220
+ """
221
+ # ⚖️ LegalIntern
222
+ ### Мульти-агентна система для складних правових консультацій
223
+
224
+ Дев'ять спеціалізованих LLM-агентів працюють разом для аналізу правових питань,
225
+ пошуку судової практики у 100М+ рішень ЄДРСР, та формування структурованої консультації.
226
+
227
+ *Натхнення: [PhysicsIntern](https://huggingface.co/spaces/huggingface/physics-intern) |
228
+ Дані: [SecondLayer](https://legal.org.ua) |
229
+ Код: [GitHub](https://github.com/overthelex/secondlayer-agents)*
230
+ """,
231
+ elem_classes="main-header",
232
+ )
233
+
234
+ with gr.Tabs():
235
+ with gr.Tab("Консультація (демо)"):
236
+ with gr.Row():
237
+ with gr.Column(scale=2):
238
+ question_input = gr.Textbox(
239
+ label="Правове питання",
240
+ placeholder="Опишіть вашу правову ситуацію...",
241
+ lines=4,
242
+ )
243
+ gr.Examples(
244
+ examples=[[q] for q in EXAMPLE_QUESTIONS],
245
+ inputs=question_input,
246
+ )
247
+ run_btn = gr.Button("Запустити консультацію", variant="primary")
248
+
249
+ with gr.Column(scale=1):
250
+ status_output = gr.Textbox(label="Статус", interactive=False)
251
+
252
+ with gr.Row():
253
+ with gr.Column():
254
+ consultation_output = gr.Markdown(label="Консультація")
255
+ with gr.Column():
256
+ state_output = gr.Markdown(label="Стан дослідження")
257
+
258
+ run_btn.click(
259
+ run_demo_consultation,
260
+ inputs=[question_input],
261
+ outputs=[consultation_output, state_output, status_output],
262
+ )
263
+
264
+ with gr.Tab("Архітектура"):
265
+ gr.Markdown(ARCHITECTURE_MD)
266
+
267
+ with gr.Tab("ConsultationState (приклад)"):
268
+ with gr.Row():
269
+ with gr.Column():
270
+ gr.Markdown("### Rendered Markdown")
271
+ gr.Markdown(render_state_md(DEMO_STATE))
272
+ with gr.Column():
273
+ gr.Markdown("### Raw JSON")
274
+ gr.Code(
275
+ json.dumps(DEMO_STATE.to_dict(), ensure_ascii=False, indent=2),
276
+ language="json",
277
+ )
278
+
279
+ with gr.Tab("Порівняння з PhysicsIntern"):
280
+ gr.Markdown("""
281
+ ## PhysicsIntern vs LegalIntern
282
+
283
+ Обидві системи використовують однаковий патерн: 9 спеціалізованих агентів,
284
+ структурований стан, git-версіонування, відсутність історії у агентів.
285
+
286
+ | Компонент | PhysicsIntern | LegalIntern |
287
+ |-----------|--------------|-------------|
288
+ | **Домен** | Теоретична фізика, математика | Українське цивільне/господарське право |
289
+ | **Surveyor** | Огляд наукового ландшафту | Огляд правового ландшафту |
290
+ | **Researcher** | Аналіти��ні деривації | Пошук у ЄДРСР (100М+ рішень) |
291
+ | **Computer** | Виконання Python коду | Обчислення пені, строків, інфляційних |
292
+ | **Reviewer** | VERIFIED/REFUTED/INCONCLUSIVE | Верифікація посилань + галюцінацій |
293
+ | **Critic** | Стратегія + когерентність | Повнота аналізу + контр-аргументи |
294
+ | **State** | `ResearchState` (гіпотези, докази) | `ConsultationState` (правові позиції, докази) |
295
+ | **Tools** | Python sandbox | SecondLayer MCP API |
296
+ | **Output** | `ANSWER.md` | `CONSULTATION.md` |
297
+ | **Benchmark** | CritPT (фізика) | UA Court Decisions (6.7M) |
298
+ | **LLM** | Multi-provider (Anthropic, OpenAI, Gemini, HF) | Anthropic + OpenAI |
299
+
300
+ ### Що LegalIntern додає
301
+
302
+ 1. **SecondLayer MCP Bridge** -- прямий доступ до ЄДРСР, zakon.rada.gov.ua, ЄСПЛ
303
+ 2. **Юридична верифікація** -- перевірка існування справ, актуальності НПА
304
+ 3. **Обчислення** -- пеня (ст. 549-552 ЦК), 3% річних (ст. 625), інфляційні, строки
305
+ 4. **Українська мова** -- всі промпти та вихід українською
306
+
307
+ ### Що SecondLayer вже має (secondlayer-core)
308
+
309
+ | Компонент | Опис |
310
+ |-----------|------|
311
+ | IntentClassifier | Класифікація запиту (LLM + regex fallback) |
312
+ | QueryPlanner | Маршрутизація до доменів (court, npa, echr) |
313
+ | ChatService | Агентний цикл з tool_use |
314
+ | CitationValidator | Перевірка посилань на рішення суду |
315
+ | HallucinationGuard | Виявлення фабрикованих номерів справ |
316
+ | ShepardizationService | Перевірка актуальності правових позицій |
317
+ | EvidenceExtractor | Витяг рішень, цитат, документів |
318
+
319
+ LegalIntern переосмислює цей pipeline як мульти-агентну систему з
320
+ експліцитним станом, критичним оглядом та арбітражем.
321
+ """)
322
+
323
+ with gr.Tab("Датасети"):
324
+ gr.Markdown("""
325
+ ## Пов'язані датасети на HuggingFace
326
+
327
+ | Датасет | Розмір | Опис |
328
+ |---------|--------|------|
329
+ | [ua-case-outcome-6m](https://huggingface.co/datasets/overthelex/ua-case-outcome-6m) | 6.7M | Повний датасет рішень суду з темпоральними спліттами |
330
+ | [ukrainian-court-decisions](https://huggingface.co/datasets/overthelex/ukrainian-court-decisions) | 428K | Збалансований бенчмарк для LEXTREME |
331
+ | [ua-court-citation-graph](https://huggingface.co/datasets/overthelex/ua-court-citation-graph) | 2.3M | Граф ко-цитування з 99.5М рішень |
332
+ | [ua-statute-retrieval](https://huggingface.co/datasets/overthelex/ua-statute-retrieval) | 396M citations | Бенчмарк пошуку законодавства |
333
+ | [ua-temporal-drift](https://huggingface.co/datasets/overthelex/ua-temporal-drift) | 428K | Дані про темпоральний дрифт |
334
+
335
+ ## Пов'язані статті
336
+
337
+ - [Temporal Decay of Co-Citation Predictability](https://arxiv.org/abs/2605.17639) (arXiv, 2025)
338
+ - [A Citation Graph from 100M Court Decisions](https://arxiv.org/abs/2605.15362) (arXiv, 2025)
339
+ - [Tokenizer Fertility on Ukrainian Legal Text](https://arxiv.org/abs/2605.14890) (arXiv, 2025)
340
+ """)
341
+
342
+ if __name__ == "__main__":
343
+ demo.launch()
problems/consumer_penalty.yaml ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ title: "Стягнення пені та 3% річних за прострочення оплати товару"
2
+ question: |
3
+ Покупець придбав товар на суму 150 000 грн за договором купівлі-продажу від 15.01.2024.
4
+ Строк оплати -- 30 днів з моменту поставки. Товар поставлено 20.01.2024.
5
+ Станом на сьогодні оплата не здійснена.
6
+
7
+ Чи може продавець стягнути пеню, 3% річних та інфляційні втрати?
8
+ Як розрахувати суми? Які строки давності? Яка судова практика ВС?
9
+ jurisdiction: civil
10
+ difficulty: medium
11
+ expected_tools:
12
+ - get_legislation # ст. 549-552, 625 ЦК
13
+ - search_court_decisions # практика ВС
14
+ - compute_penalty # розрахунок пені
problems/labor_dismissal.yaml ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ title: "Оскарження незаконного звільнення під час воєнного стану"
2
+ question: |
3
+ Працівника звільнено з посади 10.03.2026 на підставі п. 1 ч. 1 ст. 40 КЗпП
4
+ (зміни в організації виробництва та праці) під час дії воєнного стану.
5
+ Працівник стверджує, що: (1) не було попередження за 2 місяці, (2) не запропоновано
6
+ іншу роботу, (3) не враховано переважне право на залишення на роботі.
7
+
8
+ Чи є підстави для поновлення на роботі? Які компенсації можна стягнути?
9
+ Як впливає воєнний стан на процедуру звільнення?
10
+ jurisdiction: civil
11
+ difficulty: hard
12
+ expected_tools:
13
+ - get_legislation # КЗпП, Закон про воєнний стан
14
+ - search_court_decisions # практика щодо звільнення під час ВС
15
+ - search_supreme_court # позиції ВС
problems/property_dispute.yaml ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ title: "Визнання права власності на частку в квартирі (цивільне шлюбне партнерство)"
2
+ question: |
3
+ Жінка проживала з чоловіком у фактичних шлюбних відносинах (без реєстрації шлюбу)
4
+ протягом 8 років (2016-2024). За цей час було придбано квартиру, оформлену на чоловіка.
5
+ Обидва брали участь у виплаті іпотечного кредиту (є документальне підтвердження).
6
+ Чоловік відмовляється визнати її права на частку.
7
+
8
+ Чи може жінка претендувати на частку? Яка процедура? Які шанси у суді?
9
+ Яка позиція ВС щодо майна в цивільних шлюбних партнерствах?
10
+ jurisdiction: civil
11
+ difficulty: hard
12
+ expected_tools:
13
+ - get_legislation # СК, ЦК (спільна часткова власність)
14
+ - search_court_decisions
15
+ - search_supreme_court # ВП ВС щодо цивільного шлюбу
pyproject.toml ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "legal-intern"
3
+ version = "0.1.0"
4
+ description = "Multi-agent scaffolding for complex legal consultations over Ukrainian court decisions"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = "Apache-2.0"
8
+ authors = [
9
+ { name = "Volodymyr Ovcharov", email = "mcvovkes@gmail.com" },
10
+ ]
11
+ keywords = ["legal-nlp", "multi-agent", "legal-consultation", "ukraine", "court-decisions"]
12
+
13
+ dependencies = [
14
+ "anthropic>=0.40.0",
15
+ "openai>=1.50.0",
16
+ "rich>=13.7",
17
+ "pyyaml>=6.0",
18
+ "httpx>=0.27",
19
+ "pydantic>=2.7",
20
+ "tiktoken>=0.7",
21
+ ]
22
+
23
+ [project.optional-dependencies]
24
+ testing = [
25
+ "pytest>=8.0",
26
+ "pytest-cov>=5.0",
27
+ "pytest-asyncio>=0.23",
28
+ ]
29
+ quality = [
30
+ "ruff>=0.5",
31
+ ]
32
+ all-providers = [
33
+ "google-genai>=1.0",
34
+ "huggingface-hub>=0.25",
35
+ ]
36
+
37
+ [project.scripts]
38
+ legal-intern = "legal_intern.main:cli"
39
+
40
+ [build-system]
41
+ requires = ["hatchling"]
42
+ build-backend = "hatchling.build"
43
+
44
+ [tool.ruff]
45
+ target-version = "py312"
46
+ line-length = 100
47
+
48
+ [tool.ruff.lint]
49
+ select = ["E", "F", "I", "W", "UP"]
50
+
51
+ [tool.pytest.ini_options]
52
+ asyncio_mode = "auto"
53
+ testpaths = ["tests"]
src/legal_intern/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """LegalIntern -- multi-agent scaffolding for complex legal consultations."""
2
+
3
+ __version__ = "0.1.0"
src/legal_intern/agents/__init__.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .base import BaseAgent
2
+ from .surveyor import SurveyorAgent
3
+ from .planner import PlannerAgent
4
+ from .orchestrator import OrchestratorAgent
5
+ from .researcher import ResearcherAgent
6
+ from .analyst import AnalystAgent
7
+ from .reviewer import ReviewerAgent
8
+ from .critic import CriticAgent
9
+ from .adjudicator import AdjudicatorAgent
10
+ from .formatter import FormatterAgent
11
+
12
+ __all__ = [
13
+ "BaseAgent",
14
+ "SurveyorAgent",
15
+ "PlannerAgent",
16
+ "OrchestratorAgent",
17
+ "ResearcherAgent",
18
+ "AnalystAgent",
19
+ "ReviewerAgent",
20
+ "CriticAgent",
21
+ "AdjudicatorAgent",
22
+ "FormatterAgent",
23
+ ]
src/legal_intern/agents/adjudicator.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Adjudicator agent -- resolves disagreements between agents.
2
+
3
+ Analogous to physics-intern's Adjudicator.
4
+ Invoked when a critique challenges an established hypothesis or
5
+ when reviewer and researcher disagree.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from .base import AgentResult, BaseAgent
13
+
14
+ if TYPE_CHECKING:
15
+ from ..state.research_state import ConsultationState
16
+
17
+ ADJUDICATOR_SYSTEM_PROMPT = """\
18
+ Ти -- Арбітр правового дослідження (Legal Adjudicator). Тебе викликають коли \
19
+ є розбіжність між агентами або коли зауваження оскаржує встановлену позицію.
20
+
21
+ Ти отримуєш:
22
+ - Оскаржувану гіпотезу з її доказами
23
+ - Зауваження, що її оскаржує
24
+ - Контекст дослідження
25
+
26
+ Ти маєш:
27
+ 1. Об'єктивно оцінити обидві сторони
28
+ 2. Вирішити, чи гіпотеза залишається "established" чи повертається у "working"
29
+ 3. Якщо зауваження обгрунтоване -- вказати, які додаткові дослідження потрібні
30
+
31
+ Поверни JSON:
32
+ {
33
+ "decision": "uphold" | "reopen" | "refute",
34
+ "reasoning": "обгрунтування рішення",
35
+ "additional_research": ["список додаткових питань"] | null
36
+ }
37
+ """
38
+
39
+
40
+ class AdjudicatorAgent(BaseAgent):
41
+ role = "adjudicator"
42
+ description = "Арбітраж розбіжностей між агентами"
43
+
44
+ async def run(
45
+ self,
46
+ state: ConsultationState,
47
+ hypothesis_id: str = "",
48
+ critique_id: str = "",
49
+ **kwargs: Any,
50
+ ) -> AgentResult:
51
+ from ..providers import call_llm
52
+ from ..state.research_state import CritiqueStatus, HypothesisStatus
53
+
54
+ context = self._build_state_context(state)
55
+
56
+ # Find the disputed hypothesis and critique
57
+ hyp = next((h for h in state.hypotheses if h.id == hypothesis_id), None)
58
+ crit = next((c for c in state.critiques if c.id == critique_id), None)
59
+
60
+ dispute_text = ""
61
+ if hyp:
62
+ supporting = [
63
+ ev for ev in state.evidence if ev.id in hyp.supporting_evidence and not ev.refuted
64
+ ]
65
+ dispute_text += f"\n# Оскаржувана позиція: {hyp.statement}\n"
66
+ dispute_text += f"Підтверджуючі докази: {', '.join(ev.citation for ev in supporting)}\n"
67
+ if crit:
68
+ dispute_text += f"\n# Зауваження: {crit.summary}\n{crit.details}"
69
+
70
+ user_msg = f"{context}\n{dispute_text}"
71
+
72
+ response = await call_llm(
73
+ self.config,
74
+ system=ADJUDICATOR_SYSTEM_PROMPT,
75
+ user=user_msg,
76
+ model_key="adjudicator",
77
+ json_mode=True,
78
+ )
79
+
80
+ result = response.parsed_json or {}
81
+ decision = result.get("decision", "uphold")
82
+
83
+ if hyp:
84
+ if decision == "refute":
85
+ hyp.status = HypothesisStatus.REFUTED
86
+ elif decision == "reopen":
87
+ hyp.status = HypothesisStatus.WORKING
88
+
89
+ if crit:
90
+ crit.status = CritiqueStatus.RESOLVED
91
+
92
+ for q in result.get("additional_research", []) or []:
93
+ state.add_question(q, iteration=state.iteration)
94
+
95
+ return AgentResult(
96
+ success=True,
97
+ agent=self.role,
98
+ summary=f"Рішення: {decision} для {hypothesis_id}",
99
+ tokens_used=response.tokens_used,
100
+ )
src/legal_intern/agents/analyst.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Analyst agent -- performs legal calculations and procedural verification.
2
+
3
+ Analogous to physics-intern's Computer agent (code execution).
4
+ In secondlayer-core this maps to: tool calls that compute statutory deadlines,
5
+ calculate penalties (пеня, 3% річних, інфляційні), verify procedural requirements.
6
+
7
+ This agent can execute Python code for precise calculations.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ from .base import AgentResult, BaseAgent
15
+
16
+ if TYPE_CHECKING:
17
+ from ..state.research_state import ConsultationState
18
+
19
+ ANALYST_SYSTEM_PROMPT = """\
20
+ Ти -- Правовий аналітик-обчислювач (Legal Analyst). Твоя роль -- виконувати \
21
+ точні обчислення та процедурну верифікацію.
22
+
23
+ Типи завдань:
24
+ 1. **Обчислення сум**: пеня (ст. 549-552 ЦК), 3% річних (ст. 625 ЦК), \
25
+ інфляційні втрати, держмито, судові витрати
26
+ 2. **Процесуальні строки**: обчислення строків подання позову, апеляції, \
27
+ касації з урахуванням вихідних, святкових, воєнного стану
28
+ 3. **Строки давності**: загальна (3р), спеціальна (1р, 5р, 10р) з урахуванням \
29
+ зупинення/переривання
30
+ 4. **Верифікація вимог**: перевірка процесуальних передумов (досудове врегулювання, \
31
+ підсудність, належний відповідач)
32
+
33
+ Ти можеш писати та виконувати Python код для точних обчислень.
34
+
35
+ Поверни JSON:
36
+ {
37
+ "calculation_type": "penalty" | "deadline" | "limitation" | "verification",
38
+ "inputs": { ... },
39
+ "result": "точний результат",
40
+ "formula": "формула або норма, що застосована",
41
+ "code": "Python код (якщо використано)",
42
+ "notes": "застереження та умови"
43
+ }
44
+ """
45
+
46
+
47
+ class AnalystAgent(BaseAgent):
48
+ role = "analyst"
49
+ description = "Обчислення сум, строків, верифікація процедурних вимог"
50
+
51
+ async def run(
52
+ self,
53
+ state: ConsultationState,
54
+ task_description: str = "",
55
+ **kwargs: Any,
56
+ ) -> AgentResult:
57
+ from ..providers import call_llm
58
+
59
+ context = self._build_state_context(state)
60
+ user_msg = f"{context}\n\n# Завдання на обчислення\n{task_description}"
61
+
62
+ response = await call_llm(
63
+ self.config,
64
+ system=ANALYST_SYSTEM_PROMPT,
65
+ user=user_msg,
66
+ model_key="analyst",
67
+ json_mode=True,
68
+ )
69
+
70
+ result = response.parsed_json or {}
71
+
72
+ ev = state.add_evidence(
73
+ type="computation",
74
+ source="manual",
75
+ citation=result.get("formula", ""),
76
+ summary=result.get("result", response.content),
77
+ full_text=result.get("code", ""),
78
+ relevance=result.get("notes", ""),
79
+ confidence="high" if result.get("code") else "medium",
80
+ iteration=state.iteration,
81
+ )
82
+
83
+ return AgentResult(
84
+ success=True,
85
+ agent=self.role,
86
+ summary=f"Обчислення: {result.get('calculation_type', '?')} = {result.get('result', '')[:60]}",
87
+ tokens_used=response.tokens_used,
88
+ )
src/legal_intern/agents/base.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Base agent -- all agents inherit from this.
2
+
3
+ Each agent call starts from a fresh context (no conversation history).
4
+ The agent reads ConsultationState, performs its task, and mutates state via tools.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from abc import ABC, abstractmethod
11
+ from dataclasses import dataclass
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ if TYPE_CHECKING:
15
+ from ..core.config import Config
16
+ from ..core.workspace import WorkspaceManager
17
+ from ..state.research_state import ConsultationState
18
+
19
+
20
+ @dataclass
21
+ class AgentResult:
22
+ success: bool
23
+ agent: str
24
+ summary: str = ""
25
+ tokens_used: int = 0
26
+ duration_sec: float = 0.0
27
+ error: str = ""
28
+
29
+
30
+ class BaseAgent(ABC):
31
+ """Base class for all LegalIntern agents."""
32
+
33
+ role: str = "base"
34
+ description: str = ""
35
+
36
+ def __init__(self, config: Config, workspace: WorkspaceManager) -> None:
37
+ self.config = config
38
+ self.workspace = workspace
39
+
40
+ @abstractmethod
41
+ async def run(self, state: ConsultationState, **kwargs: Any) -> AgentResult:
42
+ """Execute the agent's task and mutate state."""
43
+ ...
44
+
45
+ def _build_state_context(self, state: ConsultationState) -> str:
46
+ """Render current state as a text summary for the LLM prompt."""
47
+ parts = []
48
+ parts.append(f"# Запит клієнта\n{state.client_question}")
49
+
50
+ if state.jurisdiction:
51
+ parts.append(f"**Юрисдикція**: {state.jurisdiction}")
52
+
53
+ if state.survey_summary:
54
+ parts.append(f"# Огляд правового ландшафту\n{state.survey_summary}")
55
+
56
+ if state.strategy.approach:
57
+ parts.append(f"# Стратегія\n{state.strategy.approach}")
58
+ if state.strategy.key_questions:
59
+ parts.append("**Ключові питання**: " + "; ".join(state.strategy.key_questions))
60
+ if state.strategy.relevant_legislation:
61
+ parts.append(
62
+ "**Релевантне законодавство**: "
63
+ + "; ".join(state.strategy.relevant_legislation)
64
+ )
65
+
66
+ if state.hypotheses:
67
+ parts.append("# Правові позиції")
68
+ for h in state.hypotheses:
69
+ parts.append(f"- {h.short()}")
70
+
71
+ if state.evidence:
72
+ parts.append("# Зібрані докази")
73
+ for ev in state.evidence:
74
+ parts.append(f"- {ev.short()}")
75
+
76
+ if state.open_questions():
77
+ parts.append("# Відкриті питання")
78
+ for q in state.open_questions():
79
+ parts.append(f"- [{q.id}] {q.question}")
80
+
81
+ if state.active_critiques():
82
+ parts.append("# Активні зауваження")
83
+ for c in state.active_critiques():
84
+ parts.append(f"- [{c.id}] ({c.severity}) {c.summary}")
85
+
86
+ return "\n\n".join(parts)
src/legal_intern/agents/critic.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Critic agent -- senior legal advisor that audits strategy and coherence.
2
+
3
+ Analogous to physics-intern's Deep Critic (periodic audit).
4
+ In secondlayer-core this is a new capability -- currently there's no
5
+ meta-level strategy review, only per-evidence validation.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from .base import AgentResult, BaseAgent
13
+
14
+ if TYPE_CHECKING:
15
+ from ..state.research_state import ConsultationState
16
+
17
+ CRITIC_SYSTEM_PROMPT = """\
18
+ Ти -- Старший правовий радник (Senior Legal Advisor). Твоя роль -- періодично \
19
+ перевіряти загальну стратегію та якість дослідження.
20
+
21
+ Ти маєш оцінити:
22
+ 1. **Стратегія**: чи правильно обрано підхід? Чи немає кращої правової позиції?
23
+ 2. **Повнота**: чи всі ключові аспекти розглянуто? Чи немає прогалин?
24
+ 3. **Узгодженість**: чи не суперечать докази один одному? Чи послідовна аргументація?
25
+ 4. **Посилання**: чи достатньо підкріплені позиції судовою практикою та НПА?
26
+ 5. **Ризики**: чи враховані контр-аргументи опонента?
27
+
28
+ Типи зауважень:
29
+ - "strategy": стратегія потребує ревізії (маршрутизується до Planner)
30
+ - "reasoning": помилка в міркуваннях (маршрутизується до Orchestrator)
31
+ - "completeness": прогалина в дослідженні (генерує нові RQ)
32
+ - "citation": проблема з посиланнями (маршрутизується до Researcher)
33
+
34
+ Поверни JSON:
35
+ {
36
+ "overall_assessment": "strong" | "adequate" | "weak",
37
+ "critiques": [
38
+ {
39
+ "type": "strategy" | "reasoning" | "completeness" | "citation",
40
+ "severity": "high" | "medium" | "low",
41
+ "summary": "коротке зауваження",
42
+ "details": "повне обгрунтування",
43
+ "target_hypothesis": "H-NNN" | null
44
+ }
45
+ ],
46
+ "can_proceed_to_answer": true | false
47
+ }
48
+ """
49
+
50
+
51
+ class CriticAgent(BaseAgent):
52
+ role = "critic"
53
+ description = "Аудит стратегії, повноти та узгодженості дослідження"
54
+
55
+ async def run(self, state: ConsultationState, **kwargs: Any) -> AgentResult:
56
+ from ..providers import call_llm
57
+
58
+ context = self._build_state_context(state)
59
+
60
+ response = await call_llm(
61
+ self.config,
62
+ system=CRITIC_SYSTEM_PROMPT,
63
+ user=context,
64
+ model_key="critic",
65
+ )
66
+
67
+ result = response.parsed_json or {}
68
+ critiques = result.get("critiques", [])
69
+
70
+ for c in critiques:
71
+ state.add_critique(
72
+ type=c.get("type", "reasoning"),
73
+ severity=c.get("severity", "medium"),
74
+ summary=c.get("summary", ""),
75
+ details=c.get("details", ""),
76
+ target_hypothesis=c.get("target_hypothesis", ""),
77
+ iteration=state.iteration,
78
+ )
79
+
80
+ can_proceed = result.get("can_proceed_to_answer", False)
81
+ assessment = result.get("overall_assessment", "unknown")
82
+
83
+ return AgentResult(
84
+ success=True,
85
+ agent=self.role,
86
+ summary=f"Оцінка: {assessment}, зауважень: {len(critiques)}, "
87
+ f"можна завершувати: {can_proceed}",
88
+ tokens_used=response.tokens_used,
89
+ )
src/legal_intern/agents/formatter.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Formatter agent -- produces the final consultation document.
2
+
3
+ Analogous to physics-intern's Formatter (produces ANSWER.md).
4
+ This agent takes the established hypotheses, verified evidence, and
5
+ strategy to produce a structured legal consultation in Ukrainian.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from .base import AgentResult, BaseAgent
13
+
14
+ if TYPE_CHECKING:
15
+ from ..state.research_state import ConsultationState
16
+
17
+ FORMATTER_SYSTEM_PROMPT = """\
18
+ Ти -- Оформлювач правової консультації (Legal Consultation Formatter). Твоя роль -- \
19
+ перетворити результати дослідження у структуровану правову консультацію для клієнта.
20
+
21
+ Формат консультації:
22
+
23
+ # ПРАВОВА КОНСУЛЬТАЦІЯ
24
+
25
+ ## 1. Питання клієнта
26
+ (переформулювання питання юридичною мовою)
27
+
28
+ ## 2. Правовий аналіз
29
+ ### 2.1. Застосовне законодавство
30
+ (перелік НПА та статей з коротким поясненням)
31
+
32
+ ### 2.2. Судова практика
33
+ (ключові позиції ВС та інших судів з посиланнями на конкретні справи)
34
+
35
+ ### 2.3. Аналіз ситуації клієнта
36
+ (застосування норм та практики до конкретних обставин)
37
+
38
+ ## 3. Висновок
39
+ (чітка правова позиція з обгрунтуванням)
40
+
41
+ ## 4. Рекомендації
42
+ (конкретні кроки, які клієнт має зробити)
43
+
44
+ ## 5. Ризики
45
+ (потенційні слабкі сторони позиції та контр-аргументи)
46
+
47
+ ## 6. Додатки
48
+ (обчислення сум, строків, якщо застосовно)
49
+
50
+ ---
51
+ Посилання на джерела (кожне посилання має бути верифіковане):
52
+ [1] ...
53
+
54
+ ВИМОГИ:
55
+ - Писати українською мовою
56
+ - Кожне твердження підкріплювати посиланням на EV-NNN
57
+ - Використовувати ТІЛЬКИ верифіковані (не refuted) докази
58
+ - Обчислення мають бути з конкретними цифрами
59
+ - Тон: професійний, але зрозумілий для клієнта
60
+ """
61
+
62
+
63
+ class FormatterAgent(BaseAgent):
64
+ role = "formatter"
65
+ description = "Оформлення фінальної правової консультації"
66
+
67
+ async def run(self, state: ConsultationState, **kwargs: Any) -> AgentResult:
68
+ from ..providers import call_llm
69
+
70
+ context = self._build_state_context(state)
71
+
72
+ # Build evidence reference for the formatter
73
+ verified_evidence = [ev for ev in state.evidence if not ev.refuted]
74
+ ev_ref = "\n".join(
75
+ f"- {ev.id}: [{ev.type}] {ev.citation} -- {ev.summary}" for ev in verified_evidence
76
+ )
77
+
78
+ established = state.established_hypotheses()
79
+ hyp_ref = "\n".join(f"- {h.id}: {h.statement}" for h in established)
80
+
81
+ user_msg = (
82
+ f"{context}\n\n"
83
+ f"# Встановлені правові позиції\n{hyp_ref}\n\n"
84
+ f"# Верифіковані докази ({len(verified_evidence)})\n{ev_ref}\n\n"
85
+ f"Сформуй фінальну правову консультацію."
86
+ )
87
+
88
+ response = await call_llm(
89
+ self.config,
90
+ system=FORMATTER_SYSTEM_PROMPT,
91
+ user=user_msg,
92
+ model_key="formatter",
93
+ )
94
+
95
+ state.answer = response.content
96
+
97
+ return AgentResult(
98
+ success=True,
99
+ agent=self.role,
100
+ summary=f"Консультація: {len(response.content)} символів",
101
+ tokens_used=response.tokens_used,
102
+ )
src/legal_intern/agents/orchestrator.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Orchestrator agent -- dispatches research tasks to researcher or analyst.
2
+
3
+ Analogous to physics-intern's Orchestrator: decides what to investigate next,
4
+ dispatches to Researcher (case law / legislation lookup) or Analyst (calculations),
5
+ and formulates working hypotheses from the evidence.
6
+
7
+ In secondlayer-core this maps to the ChatService agentic loop that selects
8
+ tool calls and routes between search, legislation, and analysis tools.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from .base import AgentResult, BaseAgent
16
+
17
+ if TYPE_CHECKING:
18
+ from ..state.research_state import ConsultationState
19
+
20
+ ORCHESTRATOR_SYSTEM_PROMPT = """\
21
+ Ти -- Оркестратор правового дослідження (Legal Research Orchestrator). Твоя роль -- \
22
+ координувати хід дослідження, визначаючи наступні кроки.
23
+
24
+ На основі поточного стану (стратегія, зібрані докази, відкриті питання, зауваження) \
25
+ ти маєш:
26
+
27
+ 1. Обрати наступне питання для дослідження (або відповісти на зауваження)
28
+ 2. Визначити тип завдання:
29
+ - "research": пошук судової практики, аналіз законодавства, доктрини
30
+ - "analysis": обчислення (строки, суми, пеня, інфляція, 3% річних)
31
+ 3. Сформулювати конкретне завдання для агента-виконавця
32
+ 4. Оновити або сформулювати правову позицію (гіпотезу) на основі нових доказів
33
+
34
+ Поверни JSON:
35
+ {
36
+ "task_type": "research" | "analysis",
37
+ "question_id": "RQ-NNN" (якщо прив'язано до питання),
38
+ "task_description": "конкретне завдання",
39
+ "hypothesis_update": { // optional
40
+ "id": "H-NNN" або null для нової,
41
+ "statement": "формулювання позиції"
42
+ },
43
+ "reasoning": "чому саме це завдання зараз"
44
+ }
45
+ """
46
+
47
+
48
+ class OrchestratorAgent(BaseAgent):
49
+ role = "orchestrator"
50
+ description = "Координація ходу дослідження та формулювання гіпотез"
51
+
52
+ async def run(self, state: ConsultationState, **kwargs: Any) -> AgentResult:
53
+ from ..providers import call_llm
54
+
55
+ context = self._build_state_context(state)
56
+
57
+ response = await call_llm(
58
+ self.config,
59
+ system=ORCHESTRATOR_SYSTEM_PROMPT,
60
+ user=context,
61
+ model_key="orchestrator",
62
+ json_mode=True,
63
+ )
64
+
65
+ dispatch = response.parsed_json or {}
66
+
67
+ # Update hypothesis if suggested
68
+ hyp_update = dispatch.get("hypothesis_update")
69
+ if hyp_update and hyp_update.get("statement"):
70
+ hyp_id = hyp_update.get("id")
71
+ if hyp_id:
72
+ for h in state.hypotheses:
73
+ if h.id == hyp_id:
74
+ h.statement = hyp_update["statement"]
75
+ break
76
+ else:
77
+ state.add_hypothesis(hyp_update["statement"], iteration=state.iteration)
78
+
79
+ return AgentResult(
80
+ success=True,
81
+ agent=self.role,
82
+ summary=f"Dispatch: {dispatch.get('task_type', '?')} -- "
83
+ f"{dispatch.get('task_description', '')[:60]}",
84
+ tokens_used=response.tokens_used,
85
+ )
src/legal_intern/agents/planner.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Planner agent -- produces and revises the legal research strategy.
2
+
3
+ Analogous to physics-intern's Planner: creates the initial strategy and
4
+ revises it when critiques demand. In secondlayer-core this maps to the
5
+ QueryPlanner + execution plan generation in ChatService.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from .base import AgentResult, BaseAgent
13
+
14
+ if TYPE_CHECKING:
15
+ from ..state.research_state import ConsultationState
16
+
17
+ PLANNER_SYSTEM_PROMPT = """\
18
+ Ти -- Стратег правової консультації (Legal Strategy Planner). Твоя роль -- \
19
+ розробити план дослідження правового питання клієнта.
20
+
21
+ На основі огляду правового ландшафту ти маєш:
22
+ 1. Сформулювати підхід до вирішення (стратегію)
23
+ 2. Визначити ключові правові питання, що потребують дослідження
24
+ 3. Пріоритезувати питання за важливістю
25
+ 4. Вказати, які джерела потрібно дослідити (судова практика, НПА, доктрина)
26
+ 5. Оцінити ризики обраної стратегії
27
+ 6. Визначити, які обчислення потрібні (строки, суми, пеня, інфляція)
28
+
29
+ Якщо ти отримуєш зауваження від Senior Legal Advisor -- переглянь стратегію \
30
+ відповідно до зауважень та поясни що змінилось.
31
+
32
+ Поверни структуровану стратегію у форматі JSON з полями:
33
+ - approach: str (опис підходу)
34
+ - legal_domains: list[str]
35
+ - key_questions: list[str]
36
+ - relevant_legislation: list[str]
37
+ - risk_factors: list[str]
38
+ """
39
+
40
+
41
+ class PlannerAgent(BaseAgent):
42
+ role = "planner"
43
+ description = "Розробка та ревізія стратегії дослідження"
44
+
45
+ async def run(
46
+ self,
47
+ state: ConsultationState,
48
+ revision_critique: str = "",
49
+ **kwargs: Any,
50
+ ) -> AgentResult:
51
+ from ..providers import call_llm
52
+
53
+ context = self._build_state_context(state)
54
+ user_msg = context
55
+ if revision_critique:
56
+ user_msg += f"\n\n# Зауваження до стратегії (потребує ревізії)\n{revision_critique}"
57
+
58
+ response = await call_llm(
59
+ self.config,
60
+ system=PLANNER_SYSTEM_PROMPT,
61
+ user=user_msg,
62
+ model_key="planner",
63
+ json_mode=True,
64
+ )
65
+
66
+ strategy_data = response.parsed_json or {}
67
+ state.strategy.approach = strategy_data.get("approach", response.content)
68
+ state.strategy.legal_domains = strategy_data.get("legal_domains", [])
69
+ state.strategy.key_questions = strategy_data.get("key_questions", [])
70
+ state.strategy.relevant_legislation = strategy_data.get("relevant_legislation", [])
71
+ state.strategy.risk_factors = strategy_data.get("risk_factors", [])
72
+
73
+ if revision_critique:
74
+ state.strategy.revision_history.append(
75
+ f"Iteration {state.iteration}: revised due to critique"
76
+ )
77
+
78
+ for q in state.strategy.key_questions:
79
+ if not any(rq.question == q for rq in state.questions):
80
+ state.add_question(q, iteration=state.iteration)
81
+
82
+ return AgentResult(
83
+ success=True,
84
+ agent=self.role,
85
+ summary=f"Стратегія: {len(state.strategy.key_questions)} питань, "
86
+ f"{len(state.strategy.relevant_legislation)} НПА",
87
+ tokens_used=response.tokens_used,
88
+ )
src/legal_intern/agents/researcher.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Researcher agent -- finds and analyzes case law, legislation, doctrine.
2
+
3
+ Analogous to physics-intern's Researcher (analytical reasoning).
4
+ In secondlayer-core this maps to: tool calls for search_court_decisions,
5
+ get_legislation, search_echr, plus the evidence extraction logic.
6
+
7
+ This agent has access to SecondLayer MCP tools via the tool bridge.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ from .base import AgentResult, BaseAgent
15
+
16
+ if TYPE_CHECKING:
17
+ from ..state.research_state import ConsultationState
18
+
19
+ RESEARCHER_SYSTEM_PROMPT = """\
20
+ Ти -- Правовий дослідник (Legal Researcher). Твоя роль -- знаходити та аналізувати \
21
+ правові джерела для відповіді на конкретне дослідницьке питання.
22
+
23
+ Ти маєш доступ до інструментів:
24
+ - search_court_decisions: пошук рішень суду в ЄДРСР (100М+ документів)
25
+ - get_legislation: отримання тексту НПА з zakon.rada.gov.ua
26
+ - search_echr: пошук рішень ЄСПЛ
27
+ - search_supreme_court: пошук правових позицій ВС
28
+
29
+ Для кожного знайденого джерела ти маєш:
30
+ 1. Оцінити його релевантність до питання
31
+ 2. Витягнути ключову правову позицію або норму
32
+ 3. Сформулювати як це підтримує або спростовує робочу гіпотезу
33
+ 4. Надати точне посилання (номер справи, стаття закону)
34
+
35
+ Поверни JSON з полями:
36
+ {
37
+ "findings": [
38
+ {
39
+ "type": "case_law" | "legislation" | "doctrine" | "echr",
40
+ "source": "edrsr" | "rada" | "echr",
41
+ "citation": "точне посилання",
42
+ "summary": "ключовий висновок",
43
+ "relevance": "як стосується питання",
44
+ "supports_hypothesis": "H-NNN" | null,
45
+ "opposes_hypothesis": "H-NNN" | null,
46
+ "confidence": "high" | "medium" | "low"
47
+ }
48
+ ],
49
+ "conclusion": "загальний висновок з дослідження"
50
+ }
51
+ """
52
+
53
+
54
+ class ResearcherAgent(BaseAgent):
55
+ role = "researcher"
56
+ description = "Пошук та аналіз судової практики, законодавства, доктрини"
57
+
58
+ async def run(
59
+ self,
60
+ state: ConsultationState,
61
+ task_description: str = "",
62
+ question_id: str = "",
63
+ **kwargs: Any,
64
+ ) -> AgentResult:
65
+ from ..providers import call_llm
66
+
67
+ context = self._build_state_context(state)
68
+ user_msg = f"{context}\n\n# Поточне завдання\n{task_description}"
69
+ if question_id:
70
+ user_msg += f"\n(Питання: {question_id})"
71
+
72
+ response = await call_llm(
73
+ self.config,
74
+ system=RESEARCHER_SYSTEM_PROMPT,
75
+ user=user_msg,
76
+ model_key="researcher",
77
+ tools=self._get_tools(),
78
+ json_mode=True,
79
+ )
80
+
81
+ findings = (response.parsed_json or {}).get("findings", [])
82
+ for f in findings:
83
+ ev = state.add_evidence(
84
+ type=f.get("type", "case_law"),
85
+ source=f.get("source", "edrsr"),
86
+ citation=f.get("citation", ""),
87
+ summary=f.get("summary", ""),
88
+ relevance=f.get("relevance", ""),
89
+ confidence=f.get("confidence", "medium"),
90
+ iteration=state.iteration,
91
+ )
92
+ # Link evidence to hypotheses
93
+ if f.get("supports_hypothesis"):
94
+ for h in state.hypotheses:
95
+ if h.id == f["supports_hypothesis"]:
96
+ h.supporting_evidence.append(ev.id)
97
+ if f.get("opposes_hypothesis"):
98
+ for h in state.hypotheses:
99
+ if h.id == f["opposes_hypothesis"]:
100
+ h.opposing_evidence.append(ev.id)
101
+
102
+ # Resolve research question if assigned
103
+ if question_id:
104
+ for q in state.questions:
105
+ if q.id == question_id:
106
+ q.answer = (response.parsed_json or {}).get("conclusion", "")
107
+ q.evidence_ids = [ev.id for ev in state.evidence[-len(findings) :]]
108
+ from ..state.research_state import RQStatus
109
+
110
+ q.status = RQStatus.RESOLVED
111
+
112
+ return AgentResult(
113
+ success=True,
114
+ agent=self.role,
115
+ summary=f"Знайдено {len(findings)} джерел",
116
+ tokens_used=response.tokens_used,
117
+ )
118
+
119
+ def _get_tools(self) -> list[dict]:
120
+ """Return SecondLayer MCP tool definitions available to this agent."""
121
+ return [
122
+ {
123
+ "name": "search_court_decisions",
124
+ "description": "Семантичний пошук рішень суду в ЄДРСР",
125
+ "parameters": {
126
+ "query": {"type": "string"},
127
+ "jurisdiction": {"type": "string", "enum": ["civil", "commercial"]},
128
+ "limit": {"type": "integer", "default": 10},
129
+ },
130
+ },
131
+ {
132
+ "name": "get_legislation",
133
+ "description": "Отримати текст НПА за назвою або номером",
134
+ "parameters": {
135
+ "query": {"type": "string"},
136
+ "article": {"type": "string"},
137
+ },
138
+ },
139
+ {
140
+ "name": "search_supreme_court",
141
+ "description": "Пошук правових позицій Верховного Суду",
142
+ "parameters": {
143
+ "query": {"type": "string"},
144
+ "category": {"type": "string"},
145
+ },
146
+ },
147
+ ]
src/legal_intern/agents/reviewer.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reviewer agent -- adversarial review of evidence and reasoning.
2
+
3
+ Analogous to physics-intern's Reviewer (auto-triggered after each evidence).
4
+ In secondlayer-core this maps to: CitationValidator + HallucinationGuard.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from .base import AgentResult, BaseAgent
12
+
13
+ if TYPE_CHECKING:
14
+ from ..state.research_state import ConsultationState
15
+
16
+ REVIEWER_SYSTEM_PROMPT = """\
17
+ Ти -- Правовий рецензент (Legal Reviewer). Твоя роль -- критично перевіряти \
18
+ докази та міркування інших агентів.
19
+
20
+ Для кожного нового доказу ти маєш:
21
+ 1. **Верифікація посилань**: чи існує вказана справа/стаття? Чи правильно цитовано?
22
+ 2. **Логічна послідовність**: чи слідує висновок з наведених норм та фактів?
23
+ 3. **Релевантність**: чи дійсно це стосується питання клієнта?
24
+ 4. **Актуальність**: чи не застарів правовий акт? Чи немає нових редакцій?
25
+ 5. **Протилежна практика**: чи є відомі контр-аргументи або протилежні позиції ВС?
26
+
27
+ Вердикт для кожного доказу:
28
+ - VERIFIED: підтверджено, можна покладатися
29
+ - REFUTED: містить помилки, не можна використовувати
30
+ - INCONCLUSIVE: потребує додаткової перевірки
31
+
32
+ Поверни JSON:
33
+ {
34
+ "reviews": [
35
+ {
36
+ "evidence_id": "EV-NNN",
37
+ "verdict": "verified" | "refuted" | "inconclusive",
38
+ "summary": "короткий висновок",
39
+ "details": "деталі перевірки",
40
+ "issues": ["список проблем"]
41
+ }
42
+ ]
43
+ }
44
+ """
45
+
46
+
47
+ class ReviewerAgent(BaseAgent):
48
+ role = "reviewer"
49
+ description = "Верифікація посилань, логіки, актуальності доказів"
50
+
51
+ async def run(
52
+ self,
53
+ state: ConsultationState,
54
+ evidence_ids: list[str] | None = None,
55
+ **kwargs: Any,
56
+ ) -> AgentResult:
57
+ from ..providers import call_llm
58
+ from ..state.research_state import ReviewResult, Verdict
59
+
60
+ context = self._build_state_context(state)
61
+
62
+ # Focus on specific evidence or review all unreviewed
63
+ target_evidence = []
64
+ if evidence_ids:
65
+ target_evidence = [ev for ev in state.evidence if ev.id in evidence_ids]
66
+ else:
67
+ reviewed_ids = set()
68
+ for h in state.hypotheses:
69
+ for r in h.reviews:
70
+ pass # reviews are per-hypothesis, not per-evidence
71
+ target_evidence = [ev for ev in state.evidence if not ev.refuted]
72
+
73
+ if not target_evidence:
74
+ return AgentResult(success=True, agent=self.role, summary="Немає доказів для рецензування")
75
+
76
+ evidence_text = "\n".join(
77
+ f"- {ev.id}: [{ev.type}] {ev.citation} -- {ev.summary}" for ev in target_evidence
78
+ )
79
+ user_msg = f"{context}\n\n# Докази для рецензування\n{evidence_text}"
80
+
81
+ response = await call_llm(
82
+ self.config,
83
+ system=REVIEWER_SYSTEM_PROMPT,
84
+ user=user_msg,
85
+ model_key="reviewer",
86
+ json_mode=True,
87
+ )
88
+
89
+ reviews = (response.parsed_json or {}).get("reviews", [])
90
+ refuted_count = 0
91
+ for r in reviews:
92
+ ev_id = r.get("evidence_id", "")
93
+ verdict = r.get("verdict", "inconclusive")
94
+ for ev in state.evidence:
95
+ if ev.id == ev_id and verdict == "refuted":
96
+ ev.refuted = True
97
+ refuted_count += 1
98
+
99
+ return AgentResult(
100
+ success=True,
101
+ agent=self.role,
102
+ summary=f"Перевірено {len(reviews)} доказів, спростовано {refuted_count}",
103
+ tokens_used=response.tokens_used,
104
+ )
src/legal_intern/agents/surveyor.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Surveyor agent -- maps the legal landscape before the main loop.
2
+
3
+ Analogous to physics-intern's Surveyor: identifies relevant areas of law,
4
+ applicable legislation, court practice trends, and potential pitfalls.
5
+ Runs once at the start.
6
+
7
+ In secondlayer-core this corresponds to: IntentClassifier + QueryPlanner
8
+ (but here it's a full LLM agent that produces a structured survey).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from .base import AgentResult, BaseAgent
16
+
17
+ if TYPE_CHECKING:
18
+ from ..state.research_state import ConsultationState
19
+
20
+ SURVEYOR_SYSTEM_PROMPT = """\
21
+ Ти -- Правовий оглядач (Legal Surveyor). Твоя роль -- провести початковий аналіз \
22
+ правового питання клієнта та скласти карту релевантного правового ландшафту.
23
+
24
+ Ти отримуєш запит клієнта і маєш:
25
+ 1. Визначити юрисдикцію (цивільна, господарська, адміністративна, кримінальна)
26
+ 2. Ідентифікувати галузі права, що застосовуються
27
+ 3. Перелічити ключові нормативні акти та статті
28
+ 4. Визначити основні правові позиції ВС/КС, що стосуються питання
29
+ 5. Зазначити потенційні ризики та слабкі сторони
30
+ 6. Окреслити темпоральні аспекти (строки давності, процесуальні строки)
31
+
32
+ Поверни структурований огляд українською мовою у форматі Markdown.
33
+ НЕ давай відповідь на питання клієнта -- лише картуй ландшафт.
34
+ """
35
+
36
+
37
+ class SurveyorAgent(BaseAgent):
38
+ role = "surveyor"
39
+ description = "Початковий огляд правового ландшафту"
40
+
41
+ async def run(self, state: ConsultationState, **kwargs: Any) -> AgentResult:
42
+ from ..providers import call_llm
43
+
44
+ prompt = f"{SURVEYOR_SYSTEM_PROMPT}\n\n# Запит клієнта\n{state.client_question}"
45
+
46
+ response = await call_llm(
47
+ self.config,
48
+ system=SURVEYOR_SYSTEM_PROMPT,
49
+ user=state.client_question,
50
+ model_key="surveyor",
51
+ )
52
+
53
+ state.survey_summary = response.content
54
+ if response.jurisdiction:
55
+ state.jurisdiction = response.jurisdiction
56
+
57
+ return AgentResult(
58
+ success=True,
59
+ agent=self.role,
60
+ summary=f"Огляд завершено: {len(response.content)} символів",
61
+ tokens_used=response.tokens_used,
62
+ )
src/legal_intern/control/__init__.py ADDED
File without changes
src/legal_intern/core/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from .config import Config
2
+ from .workspace import WorkspaceManager
3
+
4
+ __all__ = ["Config", "WorkspaceManager"]
src/legal_intern/core/config.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Configuration for the LegalIntern system."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import yaml
10
+
11
+
12
+ @dataclass
13
+ class Config:
14
+ """System configuration, loaded from YAML."""
15
+
16
+ # LLM provider
17
+ default_provider: str = "anthropic"
18
+ default_model: str = "claude-sonnet-4-6"
19
+
20
+ # Per-agent model overrides
21
+ model_overrides: dict[str, str] = field(default_factory=dict)
22
+
23
+ # Loop control
24
+ max_iterations: int = 15
25
+ critic_every_n: int = 3 # run critic every N iterations
26
+ max_consecutive_failures: int = 3
27
+
28
+ # SecondLayer API
29
+ secondlayer_api_url: str = "https://legal.org.ua/api"
30
+ secondlayer_api_key: str = ""
31
+
32
+ # Workspace
33
+ workspace_dir: str = "workspaces"
34
+ logs_dir: str = ""
35
+
36
+ # API keys (loaded from env)
37
+ anthropic_api_key: str = ""
38
+ openai_api_key: str = ""
39
+
40
+ def model_for(self, agent_role: str) -> str:
41
+ return self.model_overrides.get(agent_role, self.default_model)
42
+
43
+ @classmethod
44
+ def from_yaml(cls, path: Path) -> Config:
45
+ data = yaml.safe_load(path.read_text()) or {}
46
+ return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
47
+
48
+ @classmethod
49
+ def from_env(cls) -> Config:
50
+ import os
51
+
52
+ config = cls()
53
+ config.anthropic_api_key = os.environ.get("ANTHROPIC_API_KEY", "")
54
+ config.openai_api_key = os.environ.get("OPENAI_API_KEY", "")
55
+ config.secondlayer_api_key = os.environ.get("SECONDLAYER_API_KEY", "")
56
+ config.secondlayer_api_url = os.environ.get(
57
+ "SECONDLAYER_API_URL", config.secondlayer_api_url
58
+ )
59
+ return config
src/legal_intern/core/workspace.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Workspace management -- git-versioned workspace for each consultation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+
9
+
10
+ class WorkspaceManager:
11
+ """Manages a git-versioned workspace directory for a consultation run."""
12
+
13
+ def __init__(self, config) -> None:
14
+ self.config = config
15
+ self.root: Path = Path(".")
16
+ self.logs_dir: Path = Path(".")
17
+
18
+ def init(self, problem_title: str) -> Path:
19
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S")
20
+ slug = problem_title[:40].replace(" ", "_").replace("/", "_")
21
+ self.root = Path(self.config.workspace_dir) / f"{ts}_{slug}"
22
+ self.root.mkdir(parents=True, exist_ok=True)
23
+
24
+ self.logs_dir = self.root / "logs"
25
+ self.logs_dir.mkdir(exist_ok=True)
26
+
27
+ (self.root / "evidence").mkdir(exist_ok=True)
28
+ (self.root / "derivations").mkdir(exist_ok=True)
29
+
30
+ subprocess.run(["git", "init"], cwd=self.root, capture_output=True)
31
+ return self.root
32
+
33
+ def snapshot(self, message: str) -> None:
34
+ """Create a git commit snapshot of the current workspace state."""
35
+ subprocess.run(["git", "add", "-A"], cwd=self.root, capture_output=True)
36
+ subprocess.run(
37
+ ["git", "commit", "-m", message, "--allow-empty"],
38
+ cwd=self.root,
39
+ capture_output=True,
40
+ )
src/legal_intern/engine.py ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LegalIntern main loop engine.
2
+
3
+ Nine-agent pipeline for complex legal consultations:
4
+
5
+ 1. Surveyor -- maps the legal landscape (runs once)
6
+ 2. Planner -- produces/revises research strategy
7
+ 3. Orchestrator -- dispatches tasks to researcher or analyst
8
+ 4. Researcher -- finds case law, legislation, doctrine (via SecondLayer MCP)
9
+ 5. Analyst -- computes deadlines, penalties, procedural checks
10
+ 6. Reviewer -- adversarial verification of evidence (auto-triggered)
11
+ 7. Critic -- periodic strategy audit (every N iterations)
12
+ 8. Adjudicator -- resolves inter-agent disagreements
13
+ 9. Formatter -- produces final consultation document
14
+
15
+ No agent carries conversation history. All state lives in ConsultationState.
16
+ The workspace is git-versioned for full reproducibility.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import time
22
+ from pathlib import Path
23
+
24
+ from rich.console import Console
25
+ from rich.panel import Panel
26
+
27
+ from .agents import (
28
+ AdjudicatorAgent,
29
+ AnalystAgent,
30
+ CriticAgent,
31
+ FormatterAgent,
32
+ OrchestratorAgent,
33
+ PlannerAgent,
34
+ ResearcherAgent,
35
+ ReviewerAgent,
36
+ SurveyorAgent,
37
+ )
38
+ from .core.config import Config
39
+ from .core.workspace import WorkspaceManager
40
+ from .state.loop_state import LoopState
41
+ from .state.research_state import ConsultationState, CritiqueStatus
42
+
43
+ console = Console()
44
+
45
+
46
+ class LegalIntern:
47
+ """Main loop for the LegalIntern consultation system."""
48
+
49
+ def __init__(self, question: str, config: Config | None = None) -> None:
50
+ self.config = config or Config.from_env()
51
+ self.workspace = WorkspaceManager(self.config)
52
+ self.workspace.init(question[:60])
53
+
54
+ self.state = ConsultationState()
55
+ self.state.client_question = question.strip()
56
+ self.state.title = question[:80]
57
+
58
+ self._loop = LoopState()
59
+
60
+ # Initialize all 9 agents
61
+ self.surveyor = SurveyorAgent(self.config, self.workspace)
62
+ self.planner = PlannerAgent(self.config, self.workspace)
63
+ self.orchestrator = OrchestratorAgent(self.config, self.workspace)
64
+ self.researcher = ResearcherAgent(self.config, self.workspace)
65
+ self.analyst = AnalystAgent(self.config, self.workspace)
66
+ self.reviewer = ReviewerAgent(self.config, self.workspace)
67
+ self.critic = CriticAgent(self.config, self.workspace)
68
+ self.adjudicator = AdjudicatorAgent(self.config, self.workspace)
69
+ self.formatter = FormatterAgent(self.config, self.workspace)
70
+
71
+ async def run(self) -> str:
72
+ """Execute the full consultation pipeline. Returns the final answer."""
73
+ console.print(Panel(f"[bold]LegalIntern[/bold]\n{self.state.client_question[:200]}"))
74
+
75
+ # Phase 1: Survey
76
+ console.print("[dim]Phase 1: Surveyor[/dim]")
77
+ t0 = time.time()
78
+ result = await self.surveyor.run(self.state)
79
+ self._loop.record(0, "surveyor", result.summary, result.success, time.time() - t0)
80
+ self._loop.survey_done = True
81
+ self.workspace.snapshot("survey complete")
82
+ console.print(f" [green]{result.summary}[/green]")
83
+
84
+ # Phase 2: Plan
85
+ console.print("[dim]Phase 2: Planner[/dim]")
86
+ t0 = time.time()
87
+ result = await self.planner.run(self.state)
88
+ self._loop.record(0, "planner", result.summary, result.success, time.time() - t0)
89
+ self._loop.plan_done = True
90
+ self.workspace.snapshot("plan complete")
91
+ console.print(f" [green]{result.summary}[/green]")
92
+
93
+ # Phase 3: Main research loop
94
+ for iteration in range(1, self.config.max_iterations + 1):
95
+ self.state.iteration = iteration
96
+ console.print(f"\n[bold cyan]--- Iteration {iteration} ---[/bold cyan]")
97
+
98
+ # Orchestrator decides what to do next
99
+ t0 = time.time()
100
+ orch_result = await self.orchestrator.run(self.state)
101
+ self._loop.record(
102
+ iteration, "orchestrator", orch_result.summary, orch_result.success, time.time() - t0
103
+ )
104
+
105
+ if not orch_result.success:
106
+ if self._loop.consecutive_failures >= self.config.max_consecutive_failures:
107
+ console.print("[red]Too many consecutive failures, stopping.[/red]")
108
+ break
109
+ continue
110
+
111
+ # Parse orchestrator dispatch
112
+ dispatch = getattr(orch_result, "_dispatch", None) or {}
113
+ task_type = "research" # default
114
+ task_desc = orch_result.summary
115
+
116
+ # Execute researcher or analyst
117
+ if task_type == "analysis":
118
+ console.print(f" [yellow]Analyst:[/yellow] {task_desc[:80]}")
119
+ t0 = time.time()
120
+ agent_result = await self.analyst.run(self.state, task_description=task_desc)
121
+ else:
122
+ console.print(f" [yellow]Researcher:[/yellow] {task_desc[:80]}")
123
+ t0 = time.time()
124
+ agent_result = await self.researcher.run(self.state, task_description=task_desc)
125
+
126
+ self._loop.record(
127
+ iteration,
128
+ agent_result.agent,
129
+ agent_result.summary,
130
+ agent_result.success,
131
+ time.time() - t0,
132
+ )
133
+ console.print(f" [green]{agent_result.summary}[/green]")
134
+
135
+ # Auto-trigger reviewer
136
+ console.print(" [dim]Reviewer...[/dim]")
137
+ t0 = time.time()
138
+ review_result = await self.reviewer.run(self.state)
139
+ self._loop.record(
140
+ iteration, "reviewer", review_result.summary, review_result.success, time.time() - t0
141
+ )
142
+ console.print(f" [green]{review_result.summary}[/green]")
143
+
144
+ # Periodic critic audit
145
+ if iteration % self.config.critic_every_n == 0:
146
+ console.print(" [dim]Senior Legal Advisor...[/dim]")
147
+ t0 = time.time()
148
+ critic_result = await self.critic.run(self.state)
149
+ self._loop.record(
150
+ iteration, "critic", critic_result.summary, critic_result.success, time.time() - t0
151
+ )
152
+ self._loop.last_critic_iteration = iteration
153
+ console.print(f" [magenta]{critic_result.summary}[/magenta]")
154
+
155
+ # Route critiques
156
+ await self._route_critiques()
157
+
158
+ # Check if critic says we can proceed
159
+ if "можна завершувати: True" in critic_result.summary:
160
+ console.print("[green]Critic approved -- proceeding to answer.[/green]")
161
+ break
162
+
163
+ # Check termination: no open questions and no active critiques
164
+ if not self.state.open_questions() and not self.state.active_critiques():
165
+ console.print("[green]All questions resolved -- proceeding to answer.[/green]")
166
+ break
167
+
168
+ self.workspace.snapshot(f"iteration {iteration}")
169
+
170
+ # Phase 4: Format final answer
171
+ console.print("\n[bold]Phase 4: Formatting consultation[/bold]")
172
+ t0 = time.time()
173
+ fmt_result = await self.formatter.run(self.state)
174
+ self._loop.record(
175
+ self.state.iteration, "formatter", fmt_result.summary, fmt_result.success, time.time() - t0
176
+ )
177
+ self.workspace.snapshot("answer formatted")
178
+
179
+ # Write answer file
180
+ answer_path = self.workspace.root / "CONSULTATION.md"
181
+ answer_path.write_text(self.state.answer, encoding="utf-8")
182
+ self.workspace.snapshot("final")
183
+
184
+ console.print(Panel(f"[bold green]Done![/bold green] {answer_path}"))
185
+ self._print_summary()
186
+
187
+ return self.state.answer
188
+
189
+ async def _route_critiques(self) -> None:
190
+ """Route active critiques to appropriate agents."""
191
+ for critique in self.state.active_critiques():
192
+ if critique.type == "strategy":
193
+ result = await self.planner.run(
194
+ self.state, revision_critique=critique.details
195
+ )
196
+ critique.status = CritiqueStatus.RESOLVED
197
+ elif critique.type == "completeness":
198
+ # Generate new research questions
199
+ state_questions_before = len(self.state.questions)
200
+ result = await self.orchestrator.run(self.state)
201
+ if len(self.state.questions) > state_questions_before:
202
+ critique.status = CritiqueStatus.RESOLVED
203
+ elif critique.target_hypothesis:
204
+ result = await self.adjudicator.run(
205
+ self.state,
206
+ hypothesis_id=critique.target_hypothesis,
207
+ critique_id=critique.id,
208
+ )
209
+
210
+ def _print_summary(self) -> None:
211
+ console.print(f"\n[dim]Iterations: {self.state.iteration}[/dim]")
212
+ console.print(f"[dim]Hypotheses: {len(self.state.hypotheses)} "
213
+ f"(established: {len(self.state.established_hypotheses())})[/dim]")
214
+ console.print(f"[dim]Evidence: {len(self.state.evidence)} "
215
+ f"(refuted: {sum(1 for e in self.state.evidence if e.refuted)})[/dim]")
216
+ console.print(f"[dim]Total tokens: {self.state.total_tokens:,}[/dim]")
src/legal_intern/main.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CLI entrypoint for LegalIntern."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from .core.config import Config
11
+ from .engine import LegalIntern
12
+
13
+
14
+ def cli() -> None:
15
+ parser = argparse.ArgumentParser(description="LegalIntern -- multi-agent legal consultation")
16
+ parser.add_argument("question", nargs="?", help="Legal question (or path to .yaml problem file)")
17
+ parser.add_argument("--model", default=None, help="Override default LLM model")
18
+ parser.add_argument("--max-iterations", type=int, default=None)
19
+ parser.add_argument("--config", type=Path, default=None, help="Config YAML file")
20
+ parser.add_argument("--workspace-dir", type=str, default=None)
21
+ args = parser.parse_args()
22
+
23
+ if not args.question:
24
+ parser.print_help()
25
+ sys.exit(1)
26
+
27
+ # Load config
28
+ if args.config and args.config.exists():
29
+ config = Config.from_yaml(args.config)
30
+ else:
31
+ config = Config.from_env()
32
+
33
+ if args.model:
34
+ config.default_model = args.model
35
+ if args.max_iterations:
36
+ config.max_iterations = args.max_iterations
37
+ if args.workspace_dir:
38
+ config.workspace_dir = args.workspace_dir
39
+
40
+ # Load question from file or use directly
41
+ question = args.question
42
+ if Path(question).exists():
43
+ import yaml
44
+
45
+ data = yaml.safe_load(Path(question).read_text())
46
+ question = data.get("question", data.get("problem", question))
47
+
48
+ intern = LegalIntern(question, config)
49
+ answer = asyncio.run(intern.run())
50
+
51
+ print("\n" + "=" * 60)
52
+ print(answer)
53
+
54
+
55
+ if __name__ == "__main__":
56
+ cli()
src/legal_intern/providers/__init__.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LLM provider abstraction layer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from ..core.config import Config
11
+
12
+
13
+ @dataclass
14
+ class LLMResponse:
15
+ content: str = ""
16
+ tokens_used: int = 0
17
+ parsed_json: dict | None = None
18
+ jurisdiction: str = ""
19
+ tool_calls: list[dict] = field(default_factory=list)
20
+
21
+
22
+ class ContextTooLongError(Exception):
23
+ pass
24
+
25
+
26
+ async def call_llm(
27
+ config: Config,
28
+ system: str,
29
+ user: str,
30
+ model_key: str = "default",
31
+ json_mode: bool = False,
32
+ tools: list[dict] | None = None,
33
+ ) -> LLMResponse:
34
+ """Call the LLM provider configured for this agent role.
35
+
36
+ Each agent call starts from a fresh context -- no conversation history.
37
+ """
38
+ model = config.model_for(model_key)
39
+
40
+ if config.default_provider == "anthropic" or model.startswith("claude"):
41
+ return await _call_anthropic(config, system, user, model, json_mode, tools)
42
+ elif config.default_provider == "openai" or model.startswith("gpt"):
43
+ return await _call_openai(config, system, user, model, json_mode, tools)
44
+ else:
45
+ raise ValueError(f"Unknown provider: {config.default_provider}")
46
+
47
+
48
+ async def _call_anthropic(
49
+ config: Config,
50
+ system: str,
51
+ user: str,
52
+ model: str,
53
+ json_mode: bool,
54
+ tools: list[dict] | None,
55
+ ) -> LLMResponse:
56
+ import anthropic
57
+
58
+ client = anthropic.AsyncAnthropic(api_key=config.anthropic_api_key)
59
+
60
+ kwargs: dict[str, Any] = {
61
+ "model": model,
62
+ "max_tokens": 8192,
63
+ "system": system,
64
+ "messages": [{"role": "user", "content": user}],
65
+ }
66
+
67
+ if json_mode:
68
+ kwargs["messages"][0]["content"] += "\n\nВідповідай ТІЛЬКИ валідним JSON."
69
+
70
+ response = await client.messages.create(**kwargs)
71
+
72
+ content = ""
73
+ for block in response.content:
74
+ if block.type == "text":
75
+ content += block.text
76
+
77
+ parsed = None
78
+ if json_mode:
79
+ try:
80
+ # Try to extract JSON from the response
81
+ text = content.strip()
82
+ if text.startswith("```"):
83
+ text = text.split("```")[1]
84
+ if text.startswith("json"):
85
+ text = text[4:]
86
+ text = text.strip()
87
+ parsed = json.loads(text)
88
+ except (json.JSONDecodeError, IndexError):
89
+ parsed = None
90
+
91
+ tokens = response.usage.input_tokens + response.usage.output_tokens
92
+
93
+ return LLMResponse(
94
+ content=content,
95
+ tokens_used=tokens,
96
+ parsed_json=parsed,
97
+ )
98
+
99
+
100
+ async def _call_openai(
101
+ config: Config,
102
+ system: str,
103
+ user: str,
104
+ model: str,
105
+ json_mode: bool,
106
+ tools: list[dict] | None,
107
+ ) -> LLMResponse:
108
+ from openai import AsyncOpenAI
109
+
110
+ client = AsyncOpenAI(api_key=config.openai_api_key)
111
+
112
+ kwargs: dict[str, Any] = {
113
+ "model": model,
114
+ "messages": [
115
+ {"role": "system", "content": system},
116
+ {"role": "user", "content": user},
117
+ ],
118
+ "max_tokens": 8192,
119
+ }
120
+
121
+ if json_mode:
122
+ kwargs["response_format"] = {"type": "json_object"}
123
+
124
+ response = await client.chat.completions.create(**kwargs)
125
+
126
+ content = response.choices[0].message.content or ""
127
+
128
+ parsed = None
129
+ if json_mode:
130
+ try:
131
+ parsed = json.loads(content)
132
+ except json.JSONDecodeError:
133
+ parsed = None
134
+
135
+ tokens = (response.usage.prompt_tokens + response.usage.completion_tokens) if response.usage else 0
136
+
137
+ return LLMResponse(
138
+ content=content,
139
+ tokens_used=tokens,
140
+ parsed_json=parsed,
141
+ )
src/legal_intern/rendering/__init__.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Render ConsultationState to Markdown files for workspace snapshots."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from ..state.research_state import ConsultationState
9
+
10
+
11
+ def render_state_md(state: ConsultationState) -> str:
12
+ """Render the full consultation state as Markdown."""
13
+ parts = []
14
+ parts.append(f"# {state.title}")
15
+ parts.append(f"\n## Запит клієнта\n{state.client_question}")
16
+
17
+ if state.jurisdiction:
18
+ parts.append(f"**Юрисдикція**: {state.jurisdiction}")
19
+
20
+ if state.survey_summary:
21
+ parts.append(f"\n## Огляд\n{state.survey_summary}")
22
+
23
+ if state.strategy.approach:
24
+ parts.append(f"\n## Стратегія\n{state.strategy.approach}")
25
+
26
+ if state.hypotheses:
27
+ parts.append("\n## Правові позиції")
28
+ for h in state.hypotheses:
29
+ parts.append(f"- **{h.id}** ({h.status}): {h.statement}")
30
+
31
+ if state.evidence:
32
+ parts.append("\n## Докази")
33
+ for ev in state.evidence:
34
+ prefix = "~~" if ev.refuted else ""
35
+ suffix = "~~" if ev.refuted else ""
36
+ parts.append(f"- {prefix}**{ev.id}** [{ev.type}] {ev.citation}: {ev.summary}{suffix}")
37
+
38
+ if state.questions:
39
+ parts.append("\n## Дослідницькі питання")
40
+ for q in state.questions:
41
+ parts.append(f"- **{q.id}** ({q.status}): {q.question}")
42
+ if q.answer:
43
+ parts.append(f" > {q.answer[:200]}")
44
+
45
+ if state.critiques:
46
+ parts.append("\n## Зауваження")
47
+ for c in state.critiques:
48
+ parts.append(f"- [{c.status}] **{c.id}** ({c.severity}): {c.summary}")
49
+
50
+ parts.append(f"\n---\nIteration: {state.iteration}")
51
+
52
+ return "\n".join(parts)
src/legal_intern/state/__init__.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ from .research_state import ConsultationState, LegalHypothesis, LegalEvidence, ReviewResult
2
+ from .loop_state import LoopState
3
+
4
+ __all__ = [
5
+ "ConsultationState",
6
+ "LegalHypothesis",
7
+ "LegalEvidence",
8
+ "ReviewResult",
9
+ "LoopState",
10
+ ]
src/legal_intern/state/loop_state.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Loop state -- tracks iteration progress and dispatch history."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ @dataclass
9
+ class DispatchRecord:
10
+ iteration: int
11
+ agent: str
12
+ task_summary: str
13
+ success: bool
14
+ duration_sec: float = 0.0
15
+ tokens_used: int = 0
16
+ error: str = ""
17
+
18
+
19
+ @dataclass
20
+ class LoopState:
21
+ """Tracks the main loop's progress (separate from ConsultationState)."""
22
+
23
+ dispatch_history: list[DispatchRecord] = field(default_factory=list)
24
+ consecutive_failures: int = 0
25
+ survey_done: bool = False
26
+ plan_done: bool = False
27
+ last_critic_iteration: int = -1
28
+ formatter_attempts: int = 0
29
+
30
+ def record(
31
+ self,
32
+ iteration: int,
33
+ agent: str,
34
+ task_summary: str,
35
+ success: bool,
36
+ duration_sec: float = 0.0,
37
+ tokens_used: int = 0,
38
+ error: str = "",
39
+ ) -> None:
40
+ self.dispatch_history.append(
41
+ DispatchRecord(
42
+ iteration=iteration,
43
+ agent=agent,
44
+ task_summary=task_summary,
45
+ success=success,
46
+ duration_sec=duration_sec,
47
+ tokens_used=tokens_used,
48
+ error=error,
49
+ )
50
+ )
51
+ if success:
52
+ self.consecutive_failures = 0
53
+ else:
54
+ self.consecutive_failures += 1
src/legal_intern/state/research_state.py ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Consultation state -- structured representation of legal analysis.
2
+
3
+ Mirrors physics-intern's ResearchState: agents mutate state via tools,
4
+ Markdown is rendered from it for git snapshots. No agent carries
5
+ conversation history; all context lives here.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from dataclasses import asdict, dataclass, field
12
+ from enum import StrEnum
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+
17
+ class HypothesisStatus(StrEnum):
18
+ WORKING = "working"
19
+ ESTABLISHED = "established"
20
+ REFUTED = "refuted"
21
+ ABANDONED = "abandoned"
22
+
23
+
24
+ class Verdict(StrEnum):
25
+ VERIFIED = "verified"
26
+ REFUTED = "refuted"
27
+ INCONCLUSIVE = "inconclusive"
28
+
29
+
30
+ class Severity(StrEnum):
31
+ HIGH = "high"
32
+ MEDIUM = "medium"
33
+ LOW = "low"
34
+
35
+
36
+ class CritiqueStatus(StrEnum):
37
+ ACTIVE = "active"
38
+ RESOLVED = "resolved"
39
+ WITHDRAWN = "withdrawn"
40
+
41
+
42
+ class RQStatus(StrEnum):
43
+ OPEN = "open"
44
+ RESOLVED = "resolved"
45
+ ABANDONED = "abandoned"
46
+
47
+
48
+ @dataclass
49
+ class LegalEvidence:
50
+ """Evidence produced by a researcher or analyst agent."""
51
+
52
+ id: str = ""
53
+ type: str = "" # "case_law", "legislation", "doctrine", "computation"
54
+ source: str = "" # "edrsr", "rada", "echr", "manual"
55
+ summary: str = ""
56
+ full_text: str = ""
57
+ citation: str = "" # e.g. "Справа №757/12345/22" or "ст. 625 ЦК України"
58
+ relevance: str = ""
59
+ confidence: str = "" # "high", "medium", "low"
60
+ iteration: int | None = None
61
+ refuted: bool = False
62
+
63
+ def short(self) -> str:
64
+ return f"[{self.id}] {self.citation}: {self.summary[:80]}"
65
+
66
+
67
+ @dataclass
68
+ class ReviewResult:
69
+ """Review produced by the legal reviewer agent."""
70
+
71
+ verdict: str = ""
72
+ summary: str = ""
73
+ details: str = ""
74
+ iteration: int | None = None
75
+
76
+
77
+ @dataclass
78
+ class LegalHypothesis:
79
+ """A legal position or argument being developed."""
80
+
81
+ id: str = ""
82
+ statement: str = ""
83
+ status: HypothesisStatus = HypothesisStatus.WORKING
84
+ supporting_evidence: list[str] = field(default_factory=list) # evidence IDs
85
+ opposing_evidence: list[str] = field(default_factory=list)
86
+ reviews: list[ReviewResult] = field(default_factory=list)
87
+ iteration_created: int | None = None
88
+ iteration_resolved: int | None = None
89
+
90
+ def short(self) -> str:
91
+ return f"[{self.id}] ({self.status}) {self.statement[:80]}"
92
+
93
+
94
+ @dataclass
95
+ class Critique:
96
+ """Critique from the senior legal advisor."""
97
+
98
+ id: str = ""
99
+ type: str = "" # "strategy", "reasoning", "completeness", "citation"
100
+ severity: Severity = Severity.MEDIUM
101
+ status: CritiqueStatus = CritiqueStatus.ACTIVE
102
+ summary: str = ""
103
+ details: str = ""
104
+ target_hypothesis: str = ""
105
+ iteration: int | None = None
106
+
107
+
108
+ @dataclass
109
+ class ResearchQuestion:
110
+ """An open question that needs investigation."""
111
+
112
+ id: str = ""
113
+ question: str = ""
114
+ status: RQStatus = RQStatus.OPEN
115
+ assigned_to: str = "" # agent role
116
+ answer: str = ""
117
+ evidence_ids: list[str] = field(default_factory=list)
118
+ iteration_created: int | None = None
119
+
120
+
121
+ @dataclass
122
+ class LegalStrategy:
123
+ """The consultation strategy produced by the planner."""
124
+
125
+ approach: str = ""
126
+ legal_domains: list[str] = field(default_factory=list)
127
+ key_questions: list[str] = field(default_factory=list)
128
+ relevant_legislation: list[str] = field(default_factory=list)
129
+ risk_factors: list[str] = field(default_factory=list)
130
+ revision_history: list[str] = field(default_factory=list)
131
+
132
+
133
+ @dataclass
134
+ class ConsultationState:
135
+ """Central state object for a legal consultation.
136
+
137
+ All agents read from and write to this object.
138
+ No agent carries its own conversation history.
139
+ """
140
+
141
+ # Problem
142
+ client_question: str = ""
143
+ jurisdiction: str = "" # "civil", "commercial", "administrative", "criminal"
144
+ title: str = ""
145
+
146
+ # Strategy
147
+ strategy: LegalStrategy = field(default_factory=LegalStrategy)
148
+
149
+ # Hypotheses (legal positions)
150
+ hypotheses: list[LegalHypothesis] = field(default_factory=list)
151
+ _hyp_counter: int = 0
152
+
153
+ # Evidence
154
+ evidence: list[LegalEvidence] = field(default_factory=list)
155
+ _ev_counter: int = 0
156
+
157
+ # Open research questions
158
+ questions: list[ResearchQuestion] = field(default_factory=list)
159
+ _rq_counter: int = 0
160
+
161
+ # Critiques
162
+ critiques: list[Critique] = field(default_factory=list)
163
+ _crit_counter: int = 0
164
+
165
+ # Survey results (from the initial legal landscape survey)
166
+ survey_summary: str = ""
167
+
168
+ # Final answer
169
+ answer: str = ""
170
+ answer_template: str = ""
171
+
172
+ # Metadata
173
+ iteration: int = 0
174
+ total_tokens: int = 0
175
+ total_cost_usd: float = 0.0
176
+
177
+ # --- Mutation helpers ---
178
+
179
+ def add_hypothesis(self, statement: str, iteration: int | None = None) -> LegalHypothesis:
180
+ self._hyp_counter += 1
181
+ h = LegalHypothesis(
182
+ id=f"H-{self._hyp_counter:03d}",
183
+ statement=statement,
184
+ iteration_created=iteration,
185
+ )
186
+ self.hypotheses.append(h)
187
+ return h
188
+
189
+ def add_evidence(self, **kwargs: Any) -> LegalEvidence:
190
+ self._ev_counter += 1
191
+ ev = LegalEvidence(id=f"EV-{self._ev_counter:03d}", **kwargs)
192
+ self.evidence.append(ev)
193
+ return ev
194
+
195
+ def add_question(self, question: str, iteration: int | None = None) -> ResearchQuestion:
196
+ self._rq_counter += 1
197
+ rq = ResearchQuestion(
198
+ id=f"RQ-{self._rq_counter:03d}",
199
+ question=question,
200
+ iteration_created=iteration,
201
+ )
202
+ self.questions.append(rq)
203
+ return rq
204
+
205
+ def add_critique(self, **kwargs: Any) -> Critique:
206
+ self._crit_counter += 1
207
+ c = Critique(id=f"CR-{self._crit_counter:03d}", **kwargs)
208
+ self.critiques.append(c)
209
+ return c
210
+
211
+ def open_questions(self) -> list[ResearchQuestion]:
212
+ return [q for q in self.questions if q.status == RQStatus.OPEN]
213
+
214
+ def active_critiques(self) -> list[Critique]:
215
+ return [c for c in self.critiques if c.status == CritiqueStatus.ACTIVE]
216
+
217
+ def working_hypotheses(self) -> list[LegalHypothesis]:
218
+ return [h for h in self.hypotheses if h.status == HypothesisStatus.WORKING]
219
+
220
+ def established_hypotheses(self) -> list[LegalHypothesis]:
221
+ return [h for h in self.hypotheses if h.status == HypothesisStatus.ESTABLISHED]
222
+
223
+ def to_dict(self) -> dict:
224
+ return asdict(self)
225
+
226
+ def save(self, path: Path) -> None:
227
+ path.write_text(json.dumps(self.to_dict(), ensure_ascii=False, indent=2))
228
+
229
+ @classmethod
230
+ def load(cls, path: Path) -> ConsultationState:
231
+ data = json.loads(path.read_text())
232
+ state = cls()
233
+ state.client_question = data.get("client_question", "")
234
+ state.jurisdiction = data.get("jurisdiction", "")
235
+ state.title = data.get("title", "")
236
+ state.survey_summary = data.get("survey_summary", "")
237
+ state.answer = data.get("answer", "")
238
+ state.iteration = data.get("iteration", 0)
239
+ state.total_tokens = data.get("total_tokens", 0)
240
+ state.total_cost_usd = data.get("total_cost_usd", 0.0)
241
+ # Hydrate nested objects
242
+ if "strategy" in data:
243
+ state.strategy = LegalStrategy(**data["strategy"])
244
+ for h in data.get("hypotheses", []):
245
+ hyp = LegalHypothesis(**{k: v for k, v in h.items() if k != "reviews"})
246
+ hyp.reviews = [ReviewResult(**r) for r in h.get("reviews", [])]
247
+ state.hypotheses.append(hyp)
248
+ for ev in data.get("evidence", []):
249
+ state.evidence.append(LegalEvidence(**ev))
250
+ for q in data.get("questions", []):
251
+ state.questions.append(ResearchQuestion(**q))
252
+ for c in data.get("critiques", []):
253
+ state.critiques.append(Critique(**c))
254
+ return state
src/legal_intern/tools/__init__.py ADDED
File without changes
src/legal_intern/tools/secondlayer_bridge.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bridge to SecondLayer MCP backend tools.
2
+
3
+ Allows agents to call SecondLayer API tools (search_court_decisions,
4
+ get_legislation, etc.) via the HTTP API at legal.org.ua/api/tools/:toolName.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ import httpx
12
+
13
+
14
+ class SecondLayerBridge:
15
+ """HTTP client for SecondLayer MCP tool execution."""
16
+
17
+ def __init__(self, api_url: str, api_key: str) -> None:
18
+ self.api_url = api_url.rstrip("/")
19
+ self.api_key = api_key
20
+ self._client: httpx.AsyncClient | None = None
21
+
22
+ async def _get_client(self) -> httpx.AsyncClient:
23
+ if self._client is None:
24
+ self._client = httpx.AsyncClient(
25
+ base_url=self.api_url,
26
+ headers={"Authorization": f"Bearer {self.api_key}"},
27
+ timeout=60.0,
28
+ )
29
+ return self._client
30
+
31
+ async def call_tool(self, tool_name: str, params: dict[str, Any]) -> dict[str, Any]:
32
+ """Execute a SecondLayer MCP tool via HTTP API."""
33
+ client = await self._get_client()
34
+ response = await client.post(
35
+ f"/tools/{tool_name}",
36
+ json=params,
37
+ )
38
+ response.raise_for_status()
39
+ return response.json()
40
+
41
+ async def search_court_decisions(
42
+ self,
43
+ query: str,
44
+ jurisdiction: str = "civil",
45
+ limit: int = 10,
46
+ ) -> list[dict]:
47
+ result = await self.call_tool(
48
+ "search_court_decisions",
49
+ {"query": query, "jurisdiction": jurisdiction, "limit": limit},
50
+ )
51
+ return result.get("decisions", result.get("results", []))
52
+
53
+ async def get_legislation(self, query: str, article: str = "") -> dict:
54
+ params: dict[str, Any] = {"query": query}
55
+ if article:
56
+ params["article"] = article
57
+ return await self.call_tool("get_legislation", params)
58
+
59
+ async def search_supreme_court(self, query: str, category: str = "") -> list[dict]:
60
+ params: dict[str, Any] = {"query": query}
61
+ if category:
62
+ params["category"] = category
63
+ result = await self.call_tool("search_supreme_court_positions", params)
64
+ return result.get("positions", result.get("results", []))
65
+
66
+ async def close(self) -> None:
67
+ if self._client:
68
+ await self._client.aclose()
69
+ self._client = None
src/legal_intern/verification/__init__.py ADDED
File without changes
tests/__init__.py ADDED
File without changes