feat(kb): add 'caselaw' (פסיקה) as a fourth kind

Court rulings are a distinct content type — long discussion / reasoning,
no enumerated statutory hierarchy, case identifier instead of statute id —
so they get their own kind alongside law/regulation/circular rather than
being shoehorned into one of the existing three.

Migration 003 alters the CHECK constraints on kb_source.kind and
kb_ingest_job.kind to include 'caselaw'. The chunker uses the
circulars path for caselaw too (paragraph + size split, no statutory
section regex); the case identifier is captured at upload time in
kb_source.identifier.

Updates Literal types in ingest_source, admin_kb upload, and the
kb_public SearchRequest. _KINDS in s3.py grows to four so failed/
processed/inbox listings include caselaw subdirs.

Refs Task Master #18 (espocrm-extensions/KnowledgeBase)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 16:57:46 +00:00
parent 0cb89fbbe8
commit 16eeeff7b6
7 changed files with 51 additions and 10 deletions
+2 -2
View File
@@ -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),
+1 -1
View File
@@ -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
+7 -1
View File
@@ -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
+1 -1
View File
@@ -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,
+4 -4
View File
@@ -1,9 +1,9 @@
"""MinIO/S3 helpers for the insurance-kb bucket.
Layout in the bucket:
inbox/{law,regulation,circular}/<file>
processed/{law,regulation,circular}/<file>
failed/{law,regulation,circular}/<file>
inbox/{law,regulation,circular,caselaw}/<file>
processed/{law,regulation,circular,caselaw}/<file>
failed/{law,regulation,circular,caselaw}/<file>
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():
+1 -1
View File
@@ -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.
+35
View File
@@ -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 <pg-container> 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;