Practice area separation: multi-tenant axis across DB, RAG, and UI

Adds two orthogonal columns — practice_area (top-level legal domain:
appeals_committee / national_insurance / labor_law) and appeal_subtype
(building_permit / betterment_levy / compensation_197) — denormalized
into cases, documents, document_chunks, decisions, and style_corpus so
vector searches can filter without JOINs.

Why: the system handles two unrelated sub-domains under the same
appeals committee (1xxx building permits and 8xxx/9xxx betterment/197),
with different rules and writing style. Without a separation axis,
search_similar() and the block-writer's precedent lookup were free to
surface betterment-levy paragraphs while drafting a building-permit
decision — a real risk of cross-domain contamination. The same axis
also lets future domains (national insurance, labor law) coexist
without separate schemas.

Schema (V4 migration in db.py):
- ALTER ... ADD COLUMN IF NOT EXISTS on all five tables + composite
  indexes (practice_area first).
- Idempotent backfill: case_number ~ '^1' → building_permit, '^8' →
  betterment_levy, '^9' → compensation_197; propagated to documents,
  chunks, and decisions via case_id; training-corpus rows (case_id NULL)
  default to appeals_committee.

Code:
- New services/practice_area.py with derive_subtype, validate, and
  is_override + enum constants.
- db.create_case / create_document / store_chunks / create_decision
  inherit practice_area from the parent case (or take an explicit
  override for the case_id=None training corpus).
- db.search_similar and search_similar_paragraphs accept practice_area
  + appeal_subtype filters using the denormalized columns.
- tools/search.py auto-resolves the filter from case_number when given.
- block_writer._build_precedents_context now passes the active case's
  practice_area to search_similar_paragraphs — closes the contamination
  hole for the discussion-block precedent fetch.
- tools/cases.case_create auto-derives subtype from case_number; an
  explicit override that disagrees writes a case_subtype_override entry
  to audit_log so we can spot bad classifications later.
- tools/documents.document_upload_training tags new training material
  with practice_area + subtype end-to-end (corpus, document, chunks).

UI (web/static/index.html + web/app.py):
- New-case wizard gets a practice_area dropdown (others disabled until
  national_insurance / labor_law arrive) and an appeal_subtype dropdown
  with JS auto-fill from the case-number prefix; manual edits stick.
- Case header shows a blue badge with practice_area · subtype.
- CaseCreateRequest plumbs both fields through to cases_tools.case_create.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 16:36:48 +00:00
parent a8b79822bf
commit 26d09d648f
8 changed files with 468 additions and 34 deletions

View File

@@ -1964,14 +1964,26 @@ kbd {
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>סוג ערר</label>
<select id="wiz-committee-type">
<option value="רישוי">רישוי ובניה</option>
<option value="היטל השבחה">היטל השבחה</option>
<option value="פיצויים">פיצויים (ס' 197)</option>
<div class="form-group" style="max-width:200px">
<label>תחום משפטי</label>
<select id="wiz-practice-area">
<option value="appeals_committee">ועדת ערר</option>
<option value="national_insurance" disabled>ביטוח לאומי (בקרוב)</option>
<option value="labor_law" disabled>דיני עבודה (בקרוב)</option>
</select>
</div>
<div class="form-group">
<label>סוג ערר <span style="color:#888;font-size:0.85em">(מוסק אוטומטית ממספר התיק)</span></label>
<select id="wiz-appeal-subtype">
<option value="building_permit">רישוי ובנייה</option>
<option value="betterment_levy">היטל השבחה</option>
<option value="compensation_197">פיצויים (ס' 197)</option>
<option value="unknown">לא ידוע</option>
</select>
</div>
<input type="hidden" id="wiz-committee-type" value="רישוי">
</div>
<div class="form-row">
<div class="form-group">
<label>כתובת נכס</label>
<input type="text" id="wiz-address" placeholder="רח' אבינדב 23, קריית יערים">
@@ -2730,11 +2742,14 @@ function getListValues(listId) {
function buildSummary() {
const data = getWizardData();
const OUTCOME_LABELS = { rejection: 'דחייה', partial_acceptance: 'קבלה חלקית', full_acceptance: 'קבלה מלאה', betterment_levy: 'היטל השבחה' };
const PRACTICE_AREA_LABELS = { appeals_committee: 'ועדת ערר', national_insurance: 'ביטוח לאומי', labor_law: 'דיני עבודה' };
const SUBTYPE_LABELS = { building_permit: 'רישוי ובנייה', betterment_levy: 'היטל השבחה', compensation_197: "פיצויים (ס' 197)", unknown: 'לא ידוע' };
document.getElementById('wizSummary').innerHTML = `
<table style="width:100%;font-size:0.88em;border-collapse:collapse">
<tr><td style="padding:6px;color:#888;width:120px">מספר תיק</td><td style="padding:6px;font-weight:600">${esc(data.case_number)}</td></tr>
<tr><td style="padding:6px;color:#888">כותרת</td><td style="padding:6px">${esc(data.title)}</td></tr>
<tr><td style="padding:6px;color:#888">סוג</td><td style="padding:6px">${esc(data.committee_type)}</td></tr>
<tr><td style="padding:6px;color:#888">תחום</td><td style="padding:6px">${esc(PRACTICE_AREA_LABELS[data.practice_area] || data.practice_area)}</td></tr>
<tr><td style="padding:6px;color:#888">סוג ערר</td><td style="padding:6px">${esc(SUBTYPE_LABELS[data.appeal_subtype] || data.appeal_subtype)}</td></tr>
<tr><td style="padding:6px;color:#888">כתובת</td><td style="padding:6px">${esc(data.property_address || '—')}</td></tr>
<tr><td style="padding:6px;color:#888">עוררים</td><td style="padding:6px">${data.appellants.length ? data.appellants.map(esc).join(', ') : '—'}</td></tr>
<tr><td style="padding:6px;color:#888">משיבים</td><td style="padding:6px">${data.respondents.length ? data.respondents.map(esc).join(', ') : '—'}</td></tr>
@@ -2743,11 +2758,44 @@ function buildSummary() {
`;
}
// 1xxx → building_permit, 8xxx → betterment_levy, 9xxx → compensation_197
function deriveSubtypeFromCaseNumber(caseNumber) {
const m = (caseNumber || '').trim().match(/^(\d)/);
if (!m) return 'unknown';
return ({1: 'building_permit', 8: 'betterment_levy', 9: 'compensation_197'})[m[1]] || 'unknown';
}
// Auto-fill subtype + committee_type when the user types/edits the case number.
// User can override the dropdown manually afterwards.
function wireSubtypeAutofill() {
const cnInput = document.getElementById('wiz-case-number');
const subtypeSel = document.getElementById('wiz-appeal-subtype');
const committeeHidden = document.getElementById('wiz-committee-type');
if (!cnInput || !subtypeSel) return;
const SUBTYPE_TO_COMMITTEE = {
building_permit: 'רישוי',
betterment_levy: 'היטל השבחה',
compensation_197: 'פיצויים',
unknown: 'רישוי',
};
let userOverrode = false;
subtypeSel.addEventListener('change', () => { userOverrode = true; });
cnInput.addEventListener('input', () => {
if (userOverrode) return;
const derived = deriveSubtypeFromCaseNumber(cnInput.value);
subtypeSel.value = derived;
if (committeeHidden) committeeHidden.value = SUBTYPE_TO_COMMITTEE[derived];
});
}
document.addEventListener('DOMContentLoaded', wireSubtypeAutofill);
function getWizardData() {
return {
case_number: document.getElementById('wiz-case-number').value.trim(),
title: document.getElementById('wiz-title').value.trim(),
committee_type: document.getElementById('wiz-committee-type').value,
practice_area: document.getElementById('wiz-practice-area').value,
appeal_subtype: document.getElementById('wiz-appeal-subtype').value,
property_address: document.getElementById('wiz-address').value.trim(),
permit_number: document.getElementById('wiz-permit').value.trim(),
appellants: getListValues('appellantsList'),
@@ -2847,9 +2895,18 @@ async function loadCaseView(caseNumber) {
new: 'חדש', in_progress: 'בתהליך', documents_ready: 'מסמכים מוכנים',
drafted: 'טיוטה', final: 'סופי',
};
const PRACTICE_AREA_LABELS = { appeals_committee: 'ועדת ערר', national_insurance: 'ביטוח לאומי', labor_law: 'דיני עבודה' };
const SUBTYPE_LABELS = { building_permit: 'רישוי ובנייה', betterment_levy: 'היטל השבחה', compensation_197: "פיצויים (ס' 197)", unknown: 'לא ידוע' };
const meta = [];
meta.push(`<span class="badge ${data.status}">${STATUS_LABELS[data.status] || data.status}</span>`);
if (data.committee_type) meta.push(data.committee_type);
if (data.practice_area || data.appeal_subtype) {
const parts = [];
if (data.practice_area) parts.push(PRACTICE_AREA_LABELS[data.practice_area] || data.practice_area);
if (data.appeal_subtype) parts.push(SUBTYPE_LABELS[data.appeal_subtype] || data.appeal_subtype);
meta.push(`<span class="badge" style="background:#e8f0fe;color:#1a56db" title="תחום משפטי / סוג ערר">${parts.join(' · ')}</span>`);
} else if (data.committee_type) {
meta.push(data.committee_type);
}
if (data.property_address) meta.push(data.property_address);
if (data.appellants?.length) meta.push('עוררים: ' + data.appellants.join(', '));
document.getElementById('caseViewMeta').innerHTML = meta.map(m => `<span>${m}</span>`).join('');