File size: 11,300 Bytes
bf552ed
 
 
67cff6a
bf552ed
67cff6a
 
 
 
 
 
 
 
 
bf552ed
 
67cff6a
bf552ed
 
 
67cff6a
 
bf552ed
 
 
 
 
 
67cff6a
bf552ed
 
 
67cff6a
 
bf552ed
67cff6a
bf552ed
 
67cff6a
bf552ed
 
 
 
 
 
67cff6a
bf552ed
67cff6a
bf552ed
67cff6a
bf552ed
 
67cff6a
bf552ed
 
 
 
 
67cff6a
bf552ed
 
 
 
 
67cff6a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bf552ed
67cff6a
 
 
 
 
 
 
bf552ed
67cff6a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bf552ed
 
 
 
67cff6a
bf552ed
67cff6a
 
bf552ed
67cff6a
bf552ed
67cff6a
 
 
 
 
 
bf552ed
67cff6a
 
bf552ed
67cff6a
 
 
 
 
 
 
bf552ed
67cff6a
 
 
 
 
bf552ed
67cff6a
bf552ed
67cff6a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bf552ed
 
67cff6a
bf552ed
 
 
67cff6a
 
 
bf552ed
 
 
 
67cff6a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bf552ed
67cff6a
 
 
 
 
bf552ed
67cff6a
bf552ed
 
 
 
67cff6a
bf552ed
67cff6a
 
bf552ed
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
"""
Swiss Legal β€” MCP Server Patch
================================
Ersetzt Hertner's stock search_fts5 mit einem hybriden System:

  1. Harness v21 (FTS5)    β€” MRR ~0.87 auf Benchmark, sehr schnell
  2. harrier-semantic-v1   β€” Semantisches Fallback fΓΌr konzeptuelle &
                             cross-linguale Queries (DE/FR/IT)
  3. RRF-Kombination        β€” Beide Signale verschmelzen zu einem Score

Resultat: Kein Overfitting-Risiko, volle Corpus-Abdeckung, cross-lingual.

Verwendung:
    # Statt mcp_server.py direkt:
    python3 patch_mcp_server.py

    # Claude Desktop config:
    {
      "mcpServers": {
        "swiss-caselaw": {
          "command": "/path/to/.venv/bin/python3",
          "args": ["/path/to/patch_mcp_server.py"]
        }
      }
    }
"""

from __future__ import annotations
import sys, os, logging, math
from pathlib import Path

log = logging.getLogger("harness-patch")
logging.basicConfig(level=logging.INFO, stream=sys.stderr,
                    format="%(asctime)s %(levelname)s %(message)s")

# ── Locate caselaw-repo-1 ──────────────────────────────────────────────────────
_REPO_CANDIDATES = [
    Path.home() / "caselaw-repo-1",
    Path.home() / "swiss-legal" / "caselaw-repo-1",
    Path(__file__).parent,
    Path(__file__).parent.parent,
    Path("/root/caselaw-repo-1"),
]
REPO_DIR = next((p for p in _REPO_CANDIDATES if (p / "mcp_server.py").exists()), None)
if REPO_DIR is None:
    sys.exit("ERROR: mcp_server.py nicht gefunden. git clone https://github.com/jonashertner/caselaw-repo-1")
sys.path.insert(0, str(REPO_DIR))
log.info(f"Repo: {REPO_DIR}")

# ── Locate harness_v21.py ──────────────────────────────────────────────────────
_HARNESS_CANDIDATES = [
    Path(__file__).parent / "harness_v21.py",
    Path.home() / "swiss-legal" / "patch" / "harness_v21.py",
    REPO_DIR / "harness_v21.py",
    REPO_DIR / "harnesses" / "harness_no_inject_021.py",
]
HARNESS_PATH = next((p for p in _HARNESS_CANDIDATES if p.exists()), None)
if HARNESS_PATH is None:
    sys.exit("ERROR: harness_v21.py nicht gefunden. Download von ArneH/harrier-semantic-v1 (HuggingFace).")

import importlib.util
spec = importlib.util.spec_from_file_location("harness_v21", str(HARNESS_PATH))
harness = importlib.util.module_from_spec(spec)
spec.loader.exec_module(harness)
log.info(f"Harness v21: {HARNESS_PATH}")

# ── Semantic engine (harrier-semantic-v1) ──────────────────────────────────────
DATA_DIR      = Path(os.environ.get("SWISS_CASELAW_DIR", Path.home() / ".swiss-caselaw"))
SEMANTIC_DIR  = DATA_DIR / "semantic"
MODEL_DIR     = SEMANTIC_DIR / "harrier-semantic-v1"
EMB_DIR       = SEMANTIC_DIR / "embeddings"
HF_MODEL_REPO = "ArneH/harrier-semantic-v1"
HF_EMBS_REPO  = "ArneH/swiss-caselaw-embeddings"

import numpy as np

_sem_model   = None
_corpus_ids  = []
_corpus_mat  = None
_sem_ready   = False

def _get_device():
    try:
        import torch
        if torch.cuda.is_available():       return "cuda"
        if torch.backends.mps.is_available(): return "mps"
    except Exception: pass
    return "cpu"

def _load_semantic_engine():
    global _sem_model, _corpus_ids, _corpus_mat, _sem_ready
    if _sem_ready:
        return True

    # Model
    try:
        from sentence_transformers import SentenceTransformer
        device = _get_device()
        if MODEL_DIR.exists() and (MODEL_DIR / "model.safetensors").exists():
            _sem_model = SentenceTransformer(str(MODEL_DIR), device=device)
        else:
            log.info(f"Downloading harrier-semantic-v1 from HuggingFace...")
            MODEL_DIR.mkdir(parents=True, exist_ok=True)
            _sem_model = SentenceTransformer(HF_MODEL_REPO, device=device,
                                             cache_folder=str(SEMANTIC_DIR))
        log.info(f"Semantic model loaded on {device}")
    except Exception as e:
        log.warning(f"Semantic model unavailable: {e}")
        return False

    # Embeddings
    npz_files = sorted(EMB_DIR.glob("*.npz"))
    if not npz_files:
        log.info(f"Downloading corpus embeddings from HuggingFace (~740MB)...")
        try:
            from huggingface_hub import snapshot_download
            EMB_DIR.mkdir(parents=True, exist_ok=True)
            snapshot_download(repo_id=HF_EMBS_REPO, repo_type="dataset",
                              local_dir=str(EMB_DIR),
                              allow_patterns=["harrier_semantic_v1_*.npz"])
            npz_files = sorted(EMB_DIR.glob("*.npz"))
        except Exception as e:
            log.warning(f"Embeddings download failed: {e}")
            return False

    log.info(f"Loading {len(npz_files)} embedding shards...")
    ids, mats = [], []
    for f in npz_files:
        d = np.load(f)
        ids.extend(d["ids"].tolist())
        mats.append(d["embeddings"].astype(np.float32))

    _corpus_ids = ids
    mat = np.concatenate(mats, axis=0)
    norms = np.linalg.norm(mat, axis=1, keepdims=True)
    _corpus_mat = mat / np.where(norms < 1e-8, 1e-8, norms)
    _sem_ready = True
    log.info(f"Semantic engine ready: {len(_corpus_ids):,} vectors")
    return True

def _semantic_search(query: str, top_k: int = 50) -> list[tuple[str, float]]:
    """Returns list of (decision_id, cosine_score)."""
    if not _load_semantic_engine():
        return []
    try:
        q_emb = _sem_model.encode([query], normalize_embeddings=True, show_progress_bar=False)
        scores = (q_emb @ _corpus_mat.T)[0]
        top_idx = np.argsort(-scores)[:top_k]
        return [(_corpus_ids[i], float(scores[i])) for i in top_idx]
    except Exception as e:
        log.warning(f"Semantic search error: {e}")
        return []

# ── Import and patch mcp_server ────────────────────────────────────────────────
import mcp_server

_original_search_fts5 = mcp_server.search_fts5

def _hybrid_search(query: str, limit: int, filters: dict) -> tuple[list[dict], int]:
    """
    Hybrid search: harness_v21 FTS5 + harrier-semantic-v1, combined via RRF.
    Falls back to original search_fts5 for pure filter queries.
    """
    RRF_K = 60

    # 1. Harness v21 (FTS5)
    try:
        fts_raw = harness.search(query, k=limit * 4)
    except Exception as e:
        log.warning(f"Harness search failed: {e}")
        fts_raw = []

    # 2. Semantic search (harrier-semantic-v1)
    sem_raw = _semantic_search(query, top_k=limit * 3)

    # 3. RRF fusion
    rrf: dict[str, float] = {}
    for rank, r in enumerate(fts_raw, 1):
        did = r["decision_id"]
        rrf[did] = rrf.get(did, 0.0) + 0.7 / (RRF_K + rank)   # FTS5 weight: 0.7
    for rank, (did, _) in enumerate(sem_raw, 1):
        rrf[did] = rrf.get(did, 0.0) + 0.3 / (RRF_K + rank)    # Semantic weight: 0.3

    # If semantic has results but FTS5 doesn't β†’ boost semantic weight
    if not fts_raw and sem_raw:
        rrf = {}
        for rank, (did, score) in enumerate(sem_raw, 1):
            rrf[did] = 1.0 / (RRF_K + rank)

    sorted_ids = sorted(rrf, key=lambda x: -rrf[x])

    # 4. Apply filters post-hoc
    if any(filters.values()):
        try:
            db = mcp_server.get_db()
            id_list = ",".join(f"'{i.replace(chr(39), '')}'" for i in sorted_ids[:500])
            clauses, params = [], []
            for col in ("court", "canton", "language"):
                if filters.get(col):
                    clauses.append(f"{col} = ?"); params.append(filters[col])
            if filters.get("date_from"):
                clauses.append("decision_date >= ?"); params.append(filters["date_from"])
            if filters.get("date_to"):
                clauses.append("decision_date <= ?"); params.append(filters["date_to"])
            where = " AND ".join(clauses)
            allowed = {
                row[0] for row in db.execute(
                    f"SELECT decision_id FROM decisions WHERE decision_id IN ({id_list}) AND {where}",
                    params
                ).fetchall()
            }
            sorted_ids = [d for d in sorted_ids if d in allowed]
        except Exception as e:
            log.warning(f"Filter error: {e}")

    total = len(sorted_ids)
    page_ids = sorted_ids[:limit]
    if not page_ids:
        return [], 0

    # 5. Fetch full rows from DB
    try:
        db = mcp_server.get_db()
        id_list = ",".join(f"'{i.replace(chr(39), '')}'" for i in page_ids)
        rows = {
            r["decision_id"]: dict(r)
            for r in db.execute(
                f"SELECT * FROM decisions WHERE decision_id IN ({id_list})"
            ).fetchall()
        }
    except Exception as e:
        log.warning(f"DB fetch error: {e}")
        rows = {}

    results = []
    for did in page_ids:
        row = rows.get(did)
        if not row:
            continue
        row["relevance_score"] = rrf.get(did, 0.0)
        row["snippet"] = (row.get("regeste") or row.get("title") or "")[:400]
        row["citation_count"] = row.get("citation_count", 0) or 0
        results.append(row)

    return results, total


def _patched_search_fts5(query: str = "", limit: int = 50,
                          court=None, canton=None, language=None,
                          date_from=None, date_to=None,
                          chamber=None, decision_type=None,
                          legal_area=None, offset: int = 0, sort=None,
                          **kwargs):
    q = (query or "").strip()
    filters = dict(court=court, canton=canton, language=language,
                   date_from=date_from, date_to=date_to)

    # Pure filter query (no text) β†’ use original
    if not q:
        return _original_search_fts5(
            query=query, limit=limit, court=court, canton=canton,
            language=language, date_from=date_from, date_to=date_to,
            chamber=chamber, decision_type=decision_type,
            legal_area=legal_area, offset=offset, sort=sort, **kwargs
        )

    results, total = _hybrid_search(q, limit=limit + offset, filters=filters)
    return results[offset:offset + limit], total


mcp_server.search_fts5 = _patched_search_fts5
log.info("βœ“ search_fts5 β†’ Hybrid (Harness v21 FTS5 + harrier-semantic-v1)")

# Pre-load semantic engine in background
import threading
threading.Thread(target=_load_semantic_engine, daemon=True).start()

# ── Run MCP server ─────────────────────────────────────────────────────────────
if __name__ == "__main__":
    import asyncio
    if "--remote" in sys.argv:
        mcp_server.REMOTE_MODE = True
        host, port = "0.0.0.0", 8000
        for i, arg in enumerate(sys.argv):
            if arg == "--host" and i + 1 < len(sys.argv): host = sys.argv[i + 1]
            if arg == "--port" and i + 1 < len(sys.argv): port = int(sys.argv[i + 1])
        mcp_server.main_remote(host, port)
    else:
        asyncio.run(mcp_server.main_stdio())