Spaces:
Running
Running
Commit ·
a992ecb
0
Parent(s):
feat: initial LegalIntern multi-agent consultation system
Browse filesNine-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 +17 -0
- README.md +135 -0
- app.py +343 -0
- problems/consumer_penalty.yaml +14 -0
- problems/labor_dismissal.yaml +15 -0
- problems/property_dispute.yaml +15 -0
- pyproject.toml +53 -0
- src/legal_intern/__init__.py +3 -0
- src/legal_intern/agents/__init__.py +23 -0
- src/legal_intern/agents/adjudicator.py +100 -0
- src/legal_intern/agents/analyst.py +88 -0
- src/legal_intern/agents/base.py +86 -0
- src/legal_intern/agents/critic.py +89 -0
- src/legal_intern/agents/formatter.py +102 -0
- src/legal_intern/agents/orchestrator.py +85 -0
- src/legal_intern/agents/planner.py +88 -0
- src/legal_intern/agents/researcher.py +147 -0
- src/legal_intern/agents/reviewer.py +104 -0
- src/legal_intern/agents/surveyor.py +62 -0
- src/legal_intern/control/__init__.py +0 -0
- src/legal_intern/core/__init__.py +4 -0
- src/legal_intern/core/config.py +59 -0
- src/legal_intern/core/workspace.py +40 -0
- src/legal_intern/engine.py +216 -0
- src/legal_intern/main.py +56 -0
- src/legal_intern/providers/__init__.py +141 -0
- src/legal_intern/rendering/__init__.py +52 -0
- src/legal_intern/state/__init__.py +10 -0
- src/legal_intern/state/loop_state.py +54 -0
- src/legal_intern/state/research_state.py +254 -0
- src/legal_intern/tools/__init__.py +0 -0
- src/legal_intern/tools/secondlayer_bridge.py +69 -0
- src/legal_intern/verification/__init__.py +0 -0
- tests/__init__.py +0 -0
.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
|