feat(hier): 크로스-doc 절 라벨 조회 엔드포인트 (U-1, 'UG-79 보여줘' 진입점)
GET /api/documents/clause-lookup?label=UG-79 → 절 식별자로 크로스-doc 위치 해소.
절(node_type=clause/clause_split)은 in_corpus=false(검색 비활성)라 의미검색으론
못 찾으므로, 라벨 prefix 정확매칭으로 (doc, char_start)를 직접 반환해 읽기뷰 점프 가능케 함.
라벨 중복도 실측: 1335 라벨 중 다중-doc 10건(0.7%, 부록 A-/E-/F- 한정) → 에디션 UI
불요, 단순 조회 + 드문 다중반환. /{doc_id} 앞 선언(라우트 매칭 순서). document_chunks
직접 조회는 정확지목(retrieval 아님)이라 코퍼스 격리의 의도적 예외(/sections 와 동일).
A-1 후속: 절 타이핑(5180=550·5210=862·5209=43 라이브)으로 채워진 절을 사용자가 호출 가능케 함.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -672,6 +672,60 @@ async def list_duplicates(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ClauseHit(BaseModel):
|
||||||
|
doc_id: int
|
||||||
|
doc_title: str
|
||||||
|
section_title: str | None = None
|
||||||
|
char_start: int | None = None
|
||||||
|
chunk_id: int
|
||||||
|
node_type: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ClauseLookupResponse(BaseModel):
|
||||||
|
label: str
|
||||||
|
hits: list[ClauseHit]
|
||||||
|
|
||||||
|
|
||||||
|
# NOTE: '/{doc_id}' (int path param) 라우트보다 먼저 선언해야 '/clause-lookup' 이 doc_id 로
|
||||||
|
# 잘못 매칭되지 않는다 (FastAPI 선언 순서 매칭). 이동 금지.
|
||||||
|
@router.get("/clause-lookup", response_model=ClauseLookupResponse)
|
||||||
|
async def clause_lookup(
|
||||||
|
label: str,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
session: Annotated[AsyncSession, Depends(get_session)],
|
||||||
|
):
|
||||||
|
"""절 식별자(예: UG-79)로 크로스-doc 절 위치 조회 — 'UG-79 보여줘' 진입점 (U-1).
|
||||||
|
|
||||||
|
절(node_type=clause/clause_split)은 in_corpus=false(검색 비활성)라 의미검색으론 못 찾으므로,
|
||||||
|
라벨 prefix 정확매칭으로 (doc, char_start) 를 직접 해소해 읽기뷰 점프를 가능케 한다.
|
||||||
|
대부분 1건; 부록(A-/E-/F-) 등 doc 간 공유 라벨만 다중 반환(에디션 선택). /sections 와 동일하게
|
||||||
|
document_chunks 직접 조회 — corpus_chunks 우회는 retrieval 아닌 정확지목이므로 의도적 예외.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import text as sql_text
|
||||||
|
|
||||||
|
lab = (label or "").strip()
|
||||||
|
if not lab:
|
||||||
|
return ClauseLookupResponse(label=label, hits=[])
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
sql_text(
|
||||||
|
"""
|
||||||
|
SELECT c.doc_id, d.title AS doc_title, c.section_title,
|
||||||
|
c.char_start, c.id AS chunk_id, c.node_type
|
||||||
|
FROM document_chunks c
|
||||||
|
JOIN documents d ON d.id = c.doc_id
|
||||||
|
WHERE c.node_type IN ('clause', 'clause_split')
|
||||||
|
AND (c.section_title ILIKE :lab_sp OR c.section_title ILIKE :lab_eq)
|
||||||
|
AND d.deleted_at IS NULL
|
||||||
|
ORDER BY c.doc_id, c.char_start NULLS LAST
|
||||||
|
LIMIT 50
|
||||||
|
"""
|
||||||
|
).bindparams(lab_sp=lab + " %", lab_eq=lab)
|
||||||
|
)
|
||||||
|
).mappings().all()
|
||||||
|
return ClauseLookupResponse(label=lab, hits=[ClauseHit(**dict(r)) for r in rows])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{doc_id}", response_model=DocumentDetailResponse)
|
@router.get("/{doc_id}", response_model=DocumentDetailResponse)
|
||||||
async def get_document(
|
async def get_document(
|
||||||
doc_id: int,
|
doc_id: int,
|
||||||
|
|||||||
Reference in New Issue
Block a user