diff --git a/api/routes/admin_kb.py b/api/routes/admin_kb.py index 9dba0da..f0ea898 100644 --- a/api/routes/admin_kb.py +++ b/api/routes/admin_kb.py @@ -37,7 +37,7 @@ def _verify_admin(request: Request) -> None: @router.post("/ingest") async def ingest( request: Request, - kind: Literal["law", "regulation", "circular"] = Form(...), + kind: Literal["law", "regulation", "circular", "caselaw"] = Form(...), title: str = Form(...), identifier: str | None = Form(None), published_at: str | None = Form(None), @@ -226,7 +226,7 @@ _MAX_UPLOAD_BYTES = 50 * 1024 * 1024 @router.post("/upload") async def upload( request: Request, - kind: Literal["law", "regulation", "circular"] = Form(...), + kind: Literal["law", "regulation", "circular", "caselaw"] = Form(...), topic_id: int = Form(...), title: str | None = Form(None), identifier: str | None = Form(None), diff --git a/api/routes/kb_public.py b/api/routes/kb_public.py index 433465d..35f0ae9 100644 --- a/api/routes/kb_public.py +++ b/api/routes/kb_public.py @@ -36,7 +36,7 @@ def _verify_auth(request: Request) -> None: class SearchRequest(BaseModel): query: str = Field(..., min_length=1, max_length=500) - kind: Literal["any", "law", "regulation", "circular"] = "any" + kind: Literal["any", "law", "regulation", "circular", "caselaw"] = "any" top_k: int = Field(8, ge=1, le=15) # Multi-query expansion: ask the LLM for alternative phrasings, retrieve # for each, merge, then rerank against the original query. Recovers diff --git a/api/services/kb/chunker.py b/api/services/kb/chunker.py index 42014ad..df263f8 100644 --- a/api/services/kb/chunker.py +++ b/api/services/kb/chunker.py @@ -396,10 +396,16 @@ def chunk_circular( return chunks -def chunk(text: str, kind: Literal["law", "regulation", "circular"]) -> list[dict]: +def chunk(text: str, kind: Literal["law", "regulation", "circular", "caselaw"]) -> list[dict]: if kind in ("law", "regulation"): chunks = chunk_statute(text) else: + # circulars and caselaw share the same fallback: no enumerated + # statutory hierarchy, so we split by paragraph + size. The + # caselaw section_ref ends up empty (the chunker can't infer a + # case identifier from body text reliably) — the user supplies + # the case identifier via the upload form's `identifier` field + # and it's stored on the parent kb_source row instead. chunks = chunk_circular(text) # _split_oversized uses page markers still embedded in content to derive # per-piece page numbers; _as_dicts then strips markers and emits the diff --git a/api/services/kb/ingest.py b/api/services/kb/ingest.py index f4f3351..28e640f 100644 --- a/api/services/kb/ingest.py +++ b/api/services/kb/ingest.py @@ -136,7 +136,7 @@ def parse(data: bytes, filename: str) -> str: async def ingest_source( *, - kind: Literal["law", "regulation", "circular"], + kind: Literal["law", "regulation", "circular", "caselaw"], title: str, identifier: str | None, content: bytes, diff --git a/api/services/kb/s3.py b/api/services/kb/s3.py index 1906ab7..137e640 100644 --- a/api/services/kb/s3.py +++ b/api/services/kb/s3.py @@ -1,9 +1,9 @@ """MinIO/S3 helpers for the insurance-kb bucket. Layout in the bucket: - inbox/{law,regulation,circular}/ - processed/{law,regulation,circular}/ - failed/{law,regulation,circular}/ + inbox/{law,regulation,circular,caselaw}/ + processed/{law,regulation,circular,caselaw}/ + failed/{law,regulation,circular,caselaw}/ After a successful ingest, the file is moved from inbox/* to processed/*. On failure it is moved to failed/*. @@ -20,7 +20,7 @@ from botocore.client import Config as BotoConfig logger = logging.getLogger("shira.kb.s3") -_KINDS = ("law", "regulation", "circular") +_KINDS = ("law", "regulation", "circular", "caselaw") def _client(): diff --git a/api/services/kb/search.py b/api/services/kb/search.py index c8a08d8..4cf3cc3 100644 --- a/api/services/kb/search.py +++ b/api/services/kb/search.py @@ -34,7 +34,7 @@ _EXPANSION_TIMEOUT_S = 12.0 async def _retrieve_rrf( query: str, - kind: Literal["law", "regulation", "circular", "any"], + kind: Literal["law", "regulation", "circular", "caselaw", "any"], topic_id: int | None = None, ) -> list[dict]: """Run vector + lexical retrieval, fuse with RRF. No rerank, no top_k cut. diff --git a/scripts/migrations/003_caselaw_kind.sql b/scripts/migrations/003_caselaw_kind.sql new file mode 100644 index 0000000..206b60b --- /dev/null +++ b/scripts/migrations/003_caselaw_kind.sql @@ -0,0 +1,35 @@ +-- Migration 003: add 'caselaw' (פסיקה) as a fourth content kind. +-- +-- Task Master #18 (espocrm-extensions/KnowledgeBase). Until now the KB +-- holds laws / regulations / circulars only. Court rulings (פסיקה) are +-- a distinct content type — different structure (no numbered sections, +-- long discussion / reasoning, case identifier instead of statute id) — +-- so we surface it as a separate `kind` rather than abusing one of the +-- existing three. +-- +-- The CHECK constraints on kb_source.kind and kb_ingest_job.kind are +-- recreated with the expanded set. `kb_source.kind` rules every search +-- and ask path; `kb_ingest_job.kind` rules the upload form. Both must +-- agree. +-- +-- Idempotent — wrapped in a transaction; if the constraint is already +-- in the new shape, the DROP is a no-op (IF EXISTS) and the ADD just +-- re-asserts. +-- +-- Run as postgres superuser: +-- docker exec -i psql -U postgres -d insurance_kb \ +-- -f /tmp/003_caselaw_kind.sql + +BEGIN; + +ALTER TABLE kb_source DROP CONSTRAINT IF EXISTS kb_source_kind_check; +ALTER TABLE kb_source + ADD CONSTRAINT kb_source_kind_check + CHECK (kind IN ('law','regulation','circular','caselaw')); + +ALTER TABLE kb_ingest_job DROP CONSTRAINT IF EXISTS kb_ingest_job_kind_check; +ALTER TABLE kb_ingest_job + ADD CONSTRAINT kb_ingest_job_kind_check + CHECK (kind IN ('law','regulation','circular','caselaw')); + +COMMIT;