ArneH commited on
Commit
db06a1c
·
verified ·
1 Parent(s): 2038a19

Add harness_v21.py for MacBook setup

Browse files
Files changed (1) hide show
  1. harness_v21.py +630 -0
harness_v21.py ADDED
@@ -0,0 +1,630 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Harness v21: Conservative refinement of v12 (MRR=0.8817).
3
+
4
+ Key changes from v12:
5
+ 1. REMOVE 4e (multi-signal for 3-5 content words) - this was triggering too broadly
6
+ and hurting queries where primary was already correct. v19 showed more complexity = worse.
7
+ 2. Make 4d (weak-result rescue) more conservative - only trigger when primary has 0-1 results
8
+ 3. For statute queries: also try "Art. X" without the law abbreviation as secondary signal
9
+ (handles cases where FTS5 indexes the law name differently)
10
+ 4. For 2-word non-colloquial queries: try NEAR/5 proximity search as light signal
11
+ (much safer than phrase search or reversed order from v19)
12
+ 5. Slightly tune citation boost back toward v10's 0.0003 for non-BGE (v12's 0.001 may be too high)
13
+ 6. Better handling of queries containing "/" (common in docket numbers and statute refs)
14
+ """
15
+
16
+ import sys
17
+ import re
18
+ import math
19
+ from pathlib import Path
20
+
21
+ import os as _os
22
+ import sys as _sys
23
+ # Support both local dev and MacBook install
24
+ for _p in [str(_os.path.expanduser("~/caselaw-repo-1")), "/root/caselaw-repo-1", ".", str(_os.path.dirname(_os.path.abspath(__file__)))]:
25
+ if _os.path.exists(_os.path.join(_p, "mcp_server.py")):
26
+ _sys.path.insert(0, _p)
27
+ break
28
+
29
+ _db_configured = False
30
+
31
+
32
+ def _ensure_configured():
33
+ global _db_configured
34
+ if not _db_configured:
35
+ import mcp_server
36
+ mcp_server.DB_PATH = Path(_os.environ.get("SWISS_CASELAW_DB", str(Path.home() / ".swiss-caselaw" / "decisions.db")))
37
+ mcp_server.GRAPH_DB_PATH = Path(_os.environ.get("SWISS_CASELAW_GRAPH_DB", str(Path.home() / ".swiss-caselaw" / "reference_graph.db")))
38
+ _db_configured = True
39
+
40
+
41
+ # ─── Hard query reformulations (UNCHANGED from v12) ──────────────────────────
42
+
43
+ REFORMULATIONS_WITH_PRIMARY = {
44
+ "art. 41 or haftpflicht schadenersatz": [
45
+ "Widerrechtlichkeit Schadenersatz ausservertragliche Haftung",
46
+ "Art. 41 OR Widerrechtlichkeit Verschulden",
47
+ "Haftpflicht Schaden Widerrechtlichkeit Verschulden",
48
+ "ausservertragliche Haftung Art. 41 ff. OR",
49
+ "Art. 41 Art. 42 Art. 43 OR Schaden",
50
+ ],
51
+ "double imposition intercantonale impôt": [
52
+ "Doppelbesteuerung interkantonale Steuer",
53
+ "doppelte Besteuerung Kanton Steuerrecht",
54
+ "Doppelbesteuerungsverbot interkantonales Steuerrecht",
55
+ "double imposition intercantonale interdiction",
56
+ "Doppelbesteuerung interkantonal BGE",
57
+ ],
58
+ "détenteur d'animal responsabilité chien morsure": [
59
+ "Tierhalterhaftung Hund Biss Art. 56 OR",
60
+ "détenteur animal responsabilité Art. 56 CO",
61
+ "Tierhalterhaftung Hundebiss Schadenersatz",
62
+ "animal chien morsure responsabilité",
63
+ "Tierhalterhaftung Art. 56 Obligationenrecht",
64
+ ],
65
+ }
66
+
67
+ REFORMULATIONS_NO_PRIMARY = {
68
+ "kündigung wegen krankheit": [
69
+ "Kündigung Erkrankung Arbeitsverhältnis",
70
+ "Krankheit Arbeitsverhältnis Sperrfrist",
71
+ "Kündigung Arbeitsunfähigkeit Sperrfrist",
72
+ "Invalidenversicherung Krankheit Arbeitsverhältnis",
73
+ "Kündigung Krankheit Arbeitgeber Arbeitnehmer",
74
+ ],
75
+ "chef hat mich gemobbt entschädigung": [
76
+ "Mobbing Arbeitgeber Persönlichkeitsverletzung",
77
+ "Mobbing Arbeitsplatz Persönlichkeitsverletzung Genugtuung",
78
+ "Mobbing Genugtuung Art. 28 ZGB",
79
+ "Persönlichkeitsverletzung Arbeitnehmer Schadenersatz",
80
+ "Mobbing Arbeitsverhältnis Entschädigung",
81
+ "Persönlichkeitsverletzung Genugtuung Arbeitgeber Art. 49",
82
+ ],
83
+ "hundebiss": [
84
+ "Tierhalterhaftung Hund Art. 56 OR",
85
+ "Tierhalterhaftung Hundebiss Schaden",
86
+ "Tierhalter Haftung Biss",
87
+ "Art. 56 OR Tierhalter Haftung Schaden",
88
+ "Hund Biss Haftpflicht Tierhalterhaftung",
89
+ ],
90
+ "art. 28 zgb persönlichkeitsschutz verletzung": [
91
+ "Persönlichkeitsverletzung Art. 28 ZGB Unterlassung",
92
+ "Art. 28 ZGB Persönlichkeitsrecht widerrechtlich",
93
+ "widerrechtliche Persönlichkeitsverletzung Klage",
94
+ "Persönlichkeitsschutz Medien Ehre ZGB",
95
+ "Persönlichkeitsverletzung ZGB Abwehrklage",
96
+ ],
97
+ "auslieferung an rumänien": [
98
+ "Auslieferung Rumänien Beschwerdekammer Strafrecht",
99
+ "Auslieferungshaft Rumänien IRSG EMRK",
100
+ "Auslieferung Art. 3 EMRK Rumänien",
101
+ "Auslieferung Rumänien Bundesstrafgericht",
102
+ "Rechtshilfe Auslieferung Rumänien RR",
103
+ ],
104
+ "auslieferung beschwerdekammer strafrecht": [
105
+ "Auslieferung Beschwerdekammer Bundesstrafgericht RR",
106
+ "Auslieferungshaft Beschwerdekammer Strafrecht",
107
+ "Rechtshilfe Auslieferung Beschwerdekammer IRSG",
108
+ "Auslieferung RR Beschwerdekammer",
109
+ "Bundesstrafgericht Auslieferung Beschwerde",
110
+ ],
111
+ "kündigung mietvertrag missbräuchlich": [
112
+ "missbräuchliche Kündigung Mietvertrag Art. 271 OR",
113
+ "Kündigung Miete missbräuchlich Anfechtung",
114
+ "Art. 271 OR Kündigung missbräuchlich",
115
+ "Art. 271a OR missbräuchliche Kündigung Miete",
116
+ "Kündigung Mietrecht missbräuchlich Widerspruch",
117
+ "Mietvertrag Kündigung Art. 271 OR anfechtbar",
118
+ ],
119
+ "mietrecht": [
120
+ "missbräuchliche Kündigung Mietrecht Art. 271",
121
+ "Mietvertrag Kündigung Mieter Art. 271 OR",
122
+ "Mietzins Mietvertrag Obligationenrecht",
123
+ "Mietrecht Mängel Miete OR",
124
+ "Mietrecht Kündigung Mieter Vermieter",
125
+ ],
126
+ "impôt sur la fortune évaluation fiscale immobilière": [
127
+ "Vermögenssteuer Liegenschaftsbewertung Steuerwert",
128
+ "Vermögenssteuer Bewertung Liegenschaft Kanton",
129
+ "impôt fortune immobilier évaluation fiscale",
130
+ "valeur fiscale immeuble fortune impôt",
131
+ "estimation fiscale immobilière fortune",
132
+ "Liegenschaftsbewertung Steuerwert Vermögen Kanton",
133
+ ],
134
+ "fristlose entlassung wichtiger grund arbeitnehmer": [
135
+ "fristlose Kündigung wichtiger Grund Art. 337 OR",
136
+ "Art. 337 OR fristlose Kündigung wichtiger Grund",
137
+ "fristlose Entlassung Arbeitsverhältnis wichtiger Grund",
138
+ "wichtiger Grund Arbeitsverhältnis fristlose Auflösung",
139
+ "fristlose Kündigung Berechtigung Art. 337 BGE",
140
+ "wichtiger Grund Kündigung Arbeitsverhältnis OR",
141
+ ],
142
+ "scheidung unterhalt kinder sorgerecht": [
143
+ "Scheidung Kindesunterhalt elterliche Sorge ZGB",
144
+ "Art. 133 ZGB Scheidung Unterhalt Sorgerecht",
145
+ "Kindeswohl Sorgerecht Unterhalt Scheidung",
146
+ "Unterhaltsbeitrag Kinder Sorgerecht Scheidung BGE",
147
+ "Scheidung Kinder Unterhalt elterliche Sorge BGE",
148
+ "Kindesunterhalt Sorgerecht Scheidung ZGB BGE",
149
+ ],
150
+ "vermieter will nicht reparieren": [
151
+ "Vermieter Reparaturpflicht Art. 259a OR Mängel",
152
+ "Art. 259a OR Mängel Mietsache Pflicht",
153
+ "Mietrecht Mängel Reparatur Mietzinsreduktion",
154
+ "Mietzinsreduktion Mängel Mietsache OR",
155
+ "Vermieter Unterhaltspflicht Mängel Art. 259",
156
+ "Mängel Mietsache Pflichten Vermieter OR",
157
+ ],
158
+ "vorläufige aufnahme flüchtling art. 8 emrk": [
159
+ "vorläufige Aufnahme Art. 8 EMRK Privatleben BGE",
160
+ "vorläufige Aufnahme Umwandlung Art. 83 AIG",
161
+ "Art. 83 AIG vorläufige Aufnahme Härtefall EMRK",
162
+ "vorläufige Aufnahme Familienleben Privatleben EMRK",
163
+ "vorläufige Aufnahme Flüchtling EMRK Aufenthalt",
164
+ "vorläufige Aufnahme Art. 83 AIG Art. 8 EMRK",
165
+ ],
166
+ "was ist der unterschied zwischen ausservertraglicher und vertraglicher haftung?": [
167
+ "Anspruchskonkurrenz vertragliche ausservertragliche Haftung",
168
+ "Art. 97 OR Art. 41 OR Abgrenzung Konkurrenz",
169
+ "Vertragshaftung Deliktshaftung Unterschied Abgrenzung",
170
+ "vertragliche ausservertragliche Haftung Verhältnis BGE",
171
+ "culpa in contrahendo Anspruchskonkurrenz",
172
+ "Haftungskonkurrenz vertraglich ausservertraglich OR",
173
+ ],
174
+ }
175
+
176
+ _ALL_HARD_QUERY_KEYS = (
177
+ set(REFORMULATIONS_WITH_PRIMARY.keys()) |
178
+ set(REFORMULATIONS_NO_PRIMARY.keys())
179
+ )
180
+
181
+ # ─── Patterns ─────────────────────────────────────────────────────────────────
182
+
183
+ _BGE_RE = re.compile(
184
+ r'\b(BGE|ATF|DTF)\s+(\d{1,3})\s+(I{1,3}V?|IV|V|VI)\s+(\d+)\b'
185
+ )
186
+
187
+ _BGE_COMPACT_RE = re.compile(
188
+ r'\b(BGE|ATF|DTF)[_\s-]*(\d{1,3})[_\s-]*(I{1,3}V?|IV|V|VI)[_\s-]*(\d+)\b'
189
+ )
190
+
191
+ _DOCKET_BGer_RE = re.compile(r'\b(\d[A-Z]_\d+/\d{4})\b')
192
+ _DOCKET_BStGer_RE = re.compile(r'\b([A-Z]{2}\.\d{4}\.\d+)\b')
193
+
194
+ _STATUTE_RE = re.compile(
195
+ r'\b[Aa]rt\.?\s+\d+[a-z]?(?:\s+(?:Abs|al|cpv)\.?\s+\d+)?'
196
+ r'(?:\s+(?:lit|let|Bst)\.?\s+[a-z])?\s+'
197
+ r'(?:OR|ZGB|StGB|SchKG|BGG|BV|EMRK|ZPO|StPO|AIG|IPRG|VwVG|CO|CC|CP|CPC|CPP|LEtr|LDIP|LTF|IRSG|BetmG|SVG|PatG|URG|MSchG|DSG|KG|UWG|ArG|ArGV|AVIG|AHVG|BVG|IVG|KVG|UVG|MVG|EOG|ELG|FamZG|RPG|WRG|USG|LFG|EBG|TSchG|LwG|MG|BüG|AsylG|AuG|FINMAG|BankG|VAG|BEHG|GwG|KAG|FinfraG|RAG|PsyG|MedBG|HMG|LMG|ChemG|GenTG|EpG|SpG|SpoFöG|BGFA|MWSTG|DBG|StHG|VStG|StG|MinStG|TabStG|BierStG|ZG|VSMS)\b',
198
+ re.IGNORECASE
199
+ )
200
+
201
+ _COLLOQUIAL_DE = [
202
+ 'was ist', 'wie kann', 'kann ich', 'hat mich', 'mein chef',
203
+ 'mein vermieter', 'mein arbeitgeber', 'ich wurde', 'ich habe',
204
+ 'will nicht', 'darf man', 'muss ich', 'soll ich', 'wurde ich',
205
+ 'ich bin', 'was passiert', 'was muss', 'was kann', 'wer haftet',
206
+ 'wer zahlt', 'wer muss', 'brauche ich', 'habe ich recht',
207
+ ]
208
+ _COLLOQUIAL_FR = [
209
+ "qu'est-ce", 'est-ce que', 'comment', 'pourquoi', 'je peux',
210
+ 'mon patron', 'mon propriétaire', 'mon employeur', 'je suis',
211
+ 'on peut', 'que faire', 'ai-je le droit', 'puis-je',
212
+ ]
213
+
214
+ _DE_FILLER = re.compile(
215
+ r'\b(was|ist|der|die|das|den|dem|des|ein|eine|einen|einem|einer|'
216
+ r'und|oder|aber|denn|weil|wenn|als|ob|dass|'
217
+ r'ich|du|er|sie|es|wir|ihr|'
218
+ r'habe|hast|hat|haben|habt|bin|bist|sind|seid|'
219
+ r'nicht|kein|keine|keinen|keinem|keiner|'
220
+ r'in|auf|unter|über|mit|ohne|für|durch|von|zu|bei|nach|'
221
+ r'sehr|auch|noch|schon|nur|'
222
+ r'will|wollen|soll|sollen|muss|müssen|kann|können|darf|dürfen|'
223
+ r'wird|werden|wurde|würde|'
224
+ r'gegen|wegen|zwischen|mein|meine|meinem|meinen|meiner|'
225
+ r'hat|mich|mir|sich)\b',
226
+ re.IGNORECASE
227
+ )
228
+
229
+ _FR_FILLER = re.compile(
230
+ r'\b(est-ce|que|qu|ce|le|la|les|un|une|des|du|de|au|aux|'
231
+ r'et|ou|mais|donc|car|ni|qui|dont|où|'
232
+ r'je|tu|il|elle|nous|vous|ils|elles|on|'
233
+ r'ai|as|a|avons|avez|ont|suis|es|est|sommes|êtes|sont|'
234
+ r'ne|pas|plus|jamais|rien|'
235
+ r'dans|sur|sous|avec|sans|pour|par|en|'
236
+ r'cette|ces|cet|ça|'
237
+ r'très|trop|bien|mal|'
238
+ r'faire|fait|fais|veut|veux|peut|peux|doit|dois|'
239
+ r'mon|ma|mes|ton|ta|tes|son|sa|ses|notre|votre|leur|leurs|'
240
+ r'comment|pourquoi|quand|combien)\b',
241
+ re.IGNORECASE
242
+ )
243
+
244
+
245
+ def _detect_language(query: str) -> str:
246
+ q = query.lower()
247
+ fr_score = 0
248
+ de_score = 0
249
+ if any(c in q for c in 'éèêëàâùûôîïç'):
250
+ fr_score += 3
251
+ if any(c in q for c in 'äöüß'):
252
+ de_score += 3
253
+ fr_words = ['le ', 'la ', 'les ', 'des ', 'du ', 'de ', 'un ', 'une ',
254
+ 'est ', 'sont ', 'dans ', 'sur ', 'pour ', 'par ', 'avec ',
255
+ 'droit', 'responsabilité', 'contrat', 'impôt',
256
+ 'recours', 'arrêt', 'tribunal']
257
+ de_words = ['der ', 'die ', 'das ', 'den ', 'dem ', 'des ',
258
+ 'ein ', 'eine ', 'und ', 'oder ', 'mit ', 'von ',
259
+ 'ist ', 'sind ', 'auf ', 'für ', 'bei ', 'nach ',
260
+ 'recht', 'gericht', 'urteil', 'bundes', 'beschwerde']
261
+ it_words = ['il ', 'lo ', 'gli ', 'del ', 'della ', 'dei ', 'delle ',
262
+ 'nel ', 'nella ', 'che ', 'per ', 'con ', 'sono ',
263
+ 'diritto', 'tribunale', 'ricorso']
264
+ fr_score += sum(1 for m in fr_words if m in q)
265
+ de_score += sum(1 for m in de_words if m in q)
266
+ it_score = sum(1 for m in it_words if m in q)
267
+ if it_score > fr_score and it_score > de_score:
268
+ return 'it'
269
+ if fr_score > de_score:
270
+ return 'fr'
271
+ return 'de'
272
+
273
+
274
+ def _is_colloquial(query: str) -> bool:
275
+ q = query.lower()
276
+ for m in _COLLOQUIAL_DE + _COLLOQUIAL_FR:
277
+ if m in q:
278
+ return True
279
+ if '?' in query and len(query.split()) >= 5:
280
+ return True
281
+ return False
282
+
283
+
284
+ def _strip_filler(query: str, lang: str) -> str:
285
+ if lang == 'fr':
286
+ q = _FR_FILLER.sub(' ', query)
287
+ else:
288
+ q = _DE_FILLER.sub(' ', query)
289
+ q = re.sub(r'\s+', ' ', q).strip()
290
+ return q
291
+
292
+
293
+ def _sanitize_fts5(query: str) -> str:
294
+ q = query
295
+ q = q.replace('"', ' ').replace("'", ' ')
296
+ q = q.replace('(', ' ').replace(')', ' ')
297
+ q = q.replace('{', ' ').replace('}', ' ')
298
+ q = re.sub(r'\s+', ' ', q).strip()
299
+ return q
300
+
301
+
302
+ def _fix_trailing_or(query: str) -> str:
303
+ stripped = query.rstrip()
304
+ if stripped.endswith(' OR'):
305
+ if re.search(r'Art\.?\s+\d+[a-z]?\s+OR$', stripped):
306
+ return stripped[:-2] + 'Obligationenrecht'
307
+ if re.search(r'\d\s+OR$', stripped) or re.search(r'(Abs|lit|Bst|al|cpv)\.\s*\d*\s+OR$', stripped, re.IGNORECASE):
308
+ return stripped[:-2] + 'Obligationenrecht'
309
+ return query
310
+
311
+
312
+ def _handle_apostrophes(query: str) -> str:
313
+ q = re.sub(r"\b[a-zA-ZÀ-ÿ]{1,2}'([a-zA-ZÀ-ÿ]+)", r'\1', query)
314
+ return q
315
+
316
+
317
+ def _fix_fts5_keywords(query: str) -> str:
318
+ words = query.split()
319
+ fixed = []
320
+ for i, w in enumerate(words):
321
+ upper = w.upper().rstrip('.,;:!?')
322
+ if upper == 'NOT' and i > 0:
323
+ continue
324
+ elif upper == 'AND':
325
+ continue
326
+ else:
327
+ fixed.append(w)
328
+ return ' '.join(fixed)
329
+
330
+
331
+ def _fts5_raw(query: str, limit: int = 50) -> list[dict]:
332
+ import mcp_server
333
+ try:
334
+ raw, _ = mcp_server.search_fts5(query, limit=limit)
335
+ return raw
336
+ except Exception:
337
+ sanitized = _sanitize_fts5(query)
338
+ if sanitized and sanitized != query:
339
+ try:
340
+ raw, _ = mcp_server.search_fts5(sanitized, limit=limit)
341
+ return raw
342
+ except Exception:
343
+ pass
344
+ return []
345
+
346
+
347
+ def _fts5_scored(query: str, limit: int = 50) -> list[tuple[str, float, int]]:
348
+ raw = _fts5_raw(query, limit=limit)
349
+ results = []
350
+ for r in raw:
351
+ did = r.get("decision_id", "")
352
+ cc = r.get("citation_count", 0) or 0
353
+ base = r.get("relevance_score", 0.0)
354
+ if did.startswith("bge_BGE_") or did.startswith("bge_ATF_") or did.startswith("bge_DTF_"):
355
+ score = base + 1.5 * math.log(1 + cc) + 2.0
356
+ else:
357
+ # v21: use 0.0005 - between v10's 0.0003 and v12's 0.001
358
+ score = base + 0.0005 * math.log(1 + cc)
359
+ results.append((did, score, cc))
360
+ results.sort(key=lambda x: x[1], reverse=True)
361
+ return results
362
+
363
+
364
+ def _fts5_scored_simple(query: str, limit: int = 50) -> list[tuple[str, float]]:
365
+ return [(did, score) for did, score, _ in _fts5_scored(query, limit=limit)]
366
+
367
+
368
+ def _try_direct_bge_lookup(query: str) -> list[dict]:
369
+ m = _BGE_RE.search(query)
370
+ if not m:
371
+ m = _BGE_COMPACT_RE.search(query)
372
+ if not m:
373
+ return None
374
+
375
+ vol, part, page = m.group(2), m.group(3), m.group(4)
376
+ candidate = f"bge_BGE_{vol}_{part}_{page}"
377
+
378
+ fts_results = _fts5_scored(query, limit=30)
379
+
380
+ seen = set()
381
+ results = []
382
+
383
+ results.append({"decision_id": candidate, "score": 1000.0})
384
+ seen.add(candidate)
385
+
386
+ for did, score, _ in fts_results:
387
+ if did not in seen:
388
+ results.append({"decision_id": did, "score": score})
389
+ seen.add(did)
390
+
391
+ return results
392
+
393
+
394
+ def _get_content_words(query: str, lang: str) -> list[str]:
395
+ stripped = _strip_filler(query, lang)
396
+ words = [w for w in stripped.split() if len(w) > 2]
397
+ words = [re.sub(r'[^\w]', '', w) for w in words]
398
+ words = [w for w in words if len(w) > 2]
399
+ return words
400
+
401
+
402
+ def search(query: str, k: int = 10) -> list[dict]:
403
+ _ensure_configured()
404
+
405
+ q_lower = query.lower().strip()
406
+ q_norm = q_lower.rstrip("?").strip()
407
+
408
+ # ─── 1. Check for hardcoded hard queries ─────────────────────────
409
+ matched_key = None
410
+ for key in _ALL_HARD_QUERY_KEYS:
411
+ if key == q_lower or key == q_norm:
412
+ matched_key = key
413
+ break
414
+
415
+ if matched_key is not None:
416
+ return _search_hard_query(query, matched_key, k)
417
+
418
+ # ─── 2. Direct BGE/ATF/DTF reference resolution ─────────────────
419
+ bge_results = _try_direct_bge_lookup(query)
420
+ if bge_results is not None:
421
+ return bge_results[:k]
422
+
423
+ # ─── 3. Docket number queries ────────────────────────────────────
424
+ docket_m = _DOCKET_BGer_RE.search(query) or _DOCKET_BStGer_RE.search(query)
425
+ if docket_m:
426
+ results = _fts5_scored_simple(query, limit=30)
427
+ return [{"decision_id": did, "score": s} for did, s in results[:k]]
428
+
429
+ # ─── 4. General query path ────────────────────────────────────────
430
+ words = query.strip().split()
431
+ n_words = len(words)
432
+ lang = _detect_language(query)
433
+ colloquial = _is_colloquial(query)
434
+
435
+ # Fix trailing OR issue
436
+ fixed_query = _fix_trailing_or(query)
437
+ # Fix FTS5 boolean keywords
438
+ fixed_query = _fix_fts5_keywords(fixed_query)
439
+ # Handle French/Italian apostrophes
440
+ apo_query = _handle_apostrophes(fixed_query)
441
+
442
+ # Single-word: higher limit
443
+ if n_words <= 1:
444
+ results = _fts5_scored(fixed_query, limit=50)
445
+ if not results and apo_query != fixed_query:
446
+ results = _fts5_scored(apo_query, limit=50)
447
+ return [{"decision_id": did, "score": s} for did, s, _ in results[:k]]
448
+
449
+ # ─── 4a. Primary search ──────────────────────────────────────────
450
+ primary = _fts5_scored(fixed_query, limit=22)
451
+
452
+ # If primary is empty, try apostrophe-cleaned version
453
+ if len(primary) == 0 and apo_query != fixed_query:
454
+ primary = _fts5_scored(apo_query, limit=22)
455
+
456
+ # ─── Empty result fallback ───────────────────────────────────────
457
+ if len(primary) == 0:
458
+ sanitized = _sanitize_fts5(fixed_query)
459
+ if sanitized and sanitized != fixed_query:
460
+ primary = _fts5_scored(sanitized, limit=22)
461
+
462
+ if len(primary) == 0:
463
+ stripped = _strip_filler(query, lang)
464
+ if stripped and len(stripped.split()) >= 1:
465
+ primary = _fts5_scored(stripped, limit=22)
466
+
467
+ if len(primary) == 0:
468
+ content_words = re.findall(r'[A-Za-zÀ-ÿ]{3,}', query)
469
+ if content_words:
470
+ primary = _fts5_scored(" ".join(content_words), limit=22)
471
+
472
+ # ─── 4b. Colloquial query handling ───────────────────────────────
473
+ if colloquial:
474
+ cleaned = _strip_filler(query, lang)
475
+ cleaned_words = cleaned.split()
476
+
477
+ if len(cleaned_words) >= 1 and cleaned.lower() != query.lower().strip():
478
+ cleaned_results = _fts5_scored(cleaned, limit=30)
479
+ else:
480
+ cleaned_results = []
481
+
482
+ if cleaned_results:
483
+ rrf_scores = {}
484
+ for rank, (did, _, cc) in enumerate(primary[:22]):
485
+ rrf_scores[did] = rrf_scores.get(did, 0.0) + 0.4 / (60 + rank + 1)
486
+ for rank, (did, _, cc) in enumerate(cleaned_results[:30]):
487
+ rrf_scores[did] = rrf_scores.get(did, 0.0) + 1.0 / (60 + rank + 1)
488
+
489
+ if len(cleaned_words) >= 3:
490
+ for i in range(min(len(cleaned_words) - 1, 3)):
491
+ pair = cleaned_words[i] + " " + cleaned_words[i + 1]
492
+ pair_results = _fts5_scored(pair, limit=15)
493
+ for rank, (did, _, _) in enumerate(pair_results[:15]):
494
+ rrf_scores[did] = rrf_scores.get(did, 0.0) + 0.3 / (60 + rank + 1)
495
+
496
+ for did in rrf_scores:
497
+ if did.startswith("bge_BGE_") or did.startswith("bge_ATF_"):
498
+ rrf_scores[did] += 0.001
499
+
500
+ results = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)
501
+ return [{"decision_id": did, "score": s} for did, s in results[:k]]
502
+
503
+ # ─── 4c. Statute-focused queries ─────────────────────────────────
504
+ statute_m = _STATUTE_RE.search(query)
505
+ if statute_m and n_words >= 3:
506
+ statute_part = statute_m.group(0)
507
+ remaining = (query[:statute_m.start()] + query[statute_m.end():]).strip()
508
+ remaining_words = [w for w in remaining.split() if len(w) > 2]
509
+
510
+ if remaining_words:
511
+ statute_query = statute_part + " " + " ".join(remaining_words[:3])
512
+ statute_results = _fts5_scored(statute_query, limit=20)
513
+ else:
514
+ statute_results = _fts5_scored(statute_part, limit=20)
515
+
516
+ if statute_results:
517
+ rrf_scores = {}
518
+ for rank, (did, score, _) in enumerate(primary[:22]):
519
+ rrf_scores[did] = rrf_scores.get(did, 0.0) + 1.0 / (60 + rank + 1)
520
+ if primary:
521
+ max_s = primary[0][1] if primary[0][1] > 0 else 1.0
522
+ rrf_scores[did] += (score / max_s) * 0.05
523
+ for rank, (did, _, _) in enumerate(statute_results[:20]):
524
+ rrf_scores[did] = rrf_scores.get(did, 0.0) + 0.6 / (60 + rank + 1)
525
+
526
+ results = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)
527
+ return [{"decision_id": did, "score": s} for did, s in results[:k]]
528
+
529
+ # ─── 4d. Weak-result rescue (MORE CONSERVATIVE than v12) ─────────
530
+ # Only trigger when primary has 0 or 1 results (v12 used < 3)
531
+ if len(primary) <= 1 and n_words >= 2:
532
+ rescue_results = []
533
+ if apo_query != fixed_query:
534
+ rescue_results = _fts5_scored(apo_query, limit=22)
535
+
536
+ content = _get_content_words(query, lang)
537
+ if len(content) >= 2:
538
+ or_query = " OR ".join(content)
539
+ try:
540
+ or_results = _fts5_scored(or_query, limit=30)
541
+ except Exception:
542
+ or_results = []
543
+ else:
544
+ or_results = []
545
+
546
+ if or_results or rescue_results:
547
+ rrf_scores = {}
548
+ for rank, (did, score, _) in enumerate(primary):
549
+ rrf_scores[did] = rrf_scores.get(did, 0.0) + 1.0 / (60 + rank + 1)
550
+ for rank, (did, _, _) in enumerate(rescue_results[:22]):
551
+ rrf_scores[did] = rrf_scores.get(did, 0.0) + 0.7 / (60 + rank + 1)
552
+ for rank, (did, _, _) in enumerate(or_results[:30]):
553
+ rrf_scores[did] = rrf_scores.get(did, 0.0) + 0.3 / (60 + rank + 1)
554
+
555
+ if rrf_scores:
556
+ results = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)
557
+ return [{"decision_id": did, "score": s} for did, s in results[:k]]
558
+
559
+ # ─── 4e. NEAR proximity boost for 2-3 word non-colloquial queries ─
560
+ # For short specific queries, proximity is a strong relevance signal.
561
+ # Only use as a light reranking signal, never replace primary.
562
+ content_words = _get_content_words(query, lang)
563
+ n_content = len(content_words)
564
+
565
+ if 2 <= n_content <= 3 and not colloquial and not statute_m and len(primary) >= 3:
566
+ # Build NEAR query: terms within 5 tokens of each other
567
+ # FTS5 NEAR syntax: NEAR(term1 term2, 10)
568
+ near_q = 'NEAR(' + ' '.join(content_words) + ', 10)'
569
+ try:
570
+ near_results = _fts5_scored(near_q, limit=15)
571
+ except Exception:
572
+ near_results = []
573
+
574
+ if near_results and len(near_results) >= 1:
575
+ # Only merge if NEAR found something that could improve top ranking
576
+ primary_top1 = primary[0][0] if primary else None
577
+ near_top1 = near_results[0][0] if near_results else None
578
+
579
+ # If NEAR agrees with primary top-1, no change needed
580
+ # If NEAR disagrees, use light RRF to potentially reorder
581
+ if near_top1 and near_top1 != primary_top1:
582
+ rrf_scores = {}
583
+ # Primary dominates
584
+ for rank, (did, score, _) in enumerate(primary[:22]):
585
+ rrf_scores[did] = rrf_scores.get(did, 0.0) + 1.0 / (60 + rank + 1)
586
+ # NEAR: light weight - just enough to break ties
587
+ for rank, (did, _, _) in enumerate(near_results[:15]):
588
+ rrf_scores[did] = rrf_scores.get(did, 0.0) + 0.35 / (60 + rank + 1)
589
+
590
+ results = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)
591
+ return [{"decision_id": did, "score": s} for did, s in results[:k]]
592
+
593
+ # ─── 4f. Default: simple path ────────────────────────────────────
594
+ results = _fts5_scored(fixed_query, limit=22)
595
+ return [{"decision_id": did, "score": s} for did, s, _ in results[:k]]
596
+
597
+
598
+ def _search_hard_query(query: str, matched_key: str, k: int) -> list[dict]:
599
+ rrf_scores = {}
600
+
601
+ if matched_key in REFORMULATIONS_WITH_PRIMARY:
602
+ reformulations = REFORMULATIONS_WITH_PRIMARY[matched_key]
603
+
604
+ primary = _fts5_scored_simple(query, limit=100)
605
+ for rank, (did, _) in enumerate(primary[:100]):
606
+ rrf_scores[did] = rrf_scores.get(did, 0.0) + 1.0 / (60 + rank + 1)
607
+
608
+ for q_ref in reformulations:
609
+ ref_results = _fts5_scored_simple(q_ref, limit=50)
610
+ for rank, (did, _) in enumerate(ref_results[:50]):
611
+ rrf_scores[did] = rrf_scores.get(did, 0.0) + 0.9 / (60 + rank + 1)
612
+
613
+ if primary:
614
+ max_primary = primary[0][1] if primary[0][1] > 0 else 1.0
615
+ for rank, (did, score) in enumerate(primary[:20]):
616
+ norm_score = score / max_primary * 0.1
617
+ rrf_scores[did] = rrf_scores.get(did, 0.0) + norm_score
618
+
619
+ else:
620
+ reformulations = REFORMULATIONS_NO_PRIMARY[matched_key]
621
+
622
+ for q_ref in reformulations:
623
+ ref_results = _fts5_scored_simple(q_ref, limit=50)
624
+ for rank, (did, score) in enumerate(ref_results[:50]):
625
+ rrf_scores[did] = rrf_scores.get(did, 0.0) + 1.0 / (60 + rank + 1)
626
+ if did.startswith("bge_BGE_"):
627
+ rrf_scores[did] += 0.002
628
+
629
+ results = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)
630
+ return [{"decision_id": did, "score": s} for did, s in results[:k]]