Wire up query history: localStorage session persistence + sidebar UI
This commit is contained in:
@@ -10,6 +10,7 @@ from rag.answer import DISCLAIMER, GroundedAnswerService
|
||||
from rag.metrics import DECISION, TRACE_WRITE_FAILED, Metrics, NullMetrics
|
||||
from rag.models import QueryIntent, SubjectScope
|
||||
from rag.policy import resolve_subject_scope
|
||||
from rag.ports import SectionListRetriever, SectionRetriever
|
||||
from rag.telemetry import (
|
||||
annotate_current_span,
|
||||
current_correlation_id,
|
||||
@@ -24,6 +25,8 @@ class TraceWriter(Protocol):
|
||||
|
||||
def save_feedback(self, **fields: Any) -> str: ...
|
||||
|
||||
def list_by_conversation(self, conversation_id: str, limit: int) -> list[Any]: ...
|
||||
|
||||
|
||||
class RagQueryRequest(BaseModel):
|
||||
query: str = Field(min_length=1, max_length=4000)
|
||||
@@ -145,6 +148,15 @@ def _metrics(request: Request) -> Metrics:
|
||||
return getattr(request.app.state, "metrics", None) or NullMetrics()
|
||||
|
||||
|
||||
def _section_retriever(request: Request) -> SectionListRetriever:
|
||||
retriever = getattr(request.app.state, "section_retriever", None)
|
||||
if retriever is None:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Section retrieval backend is not configured"
|
||||
)
|
||||
return retriever
|
||||
|
||||
|
||||
router = APIRouter(prefix="/v1/rag", tags=["rag"])
|
||||
|
||||
|
||||
@@ -168,6 +180,57 @@ def save_feedback(
|
||||
return RagFeedbackResponse(feedback_id=feedback_id)
|
||||
|
||||
|
||||
class HistoryItem(BaseModel):
|
||||
trace_id: str
|
||||
query: str
|
||||
decision: str
|
||||
reason: str
|
||||
resolved_drug_id: str | None = None
|
||||
created_at: str
|
||||
|
||||
|
||||
class HistoryResponse(BaseModel):
|
||||
items: list[HistoryItem]
|
||||
|
||||
|
||||
_HISTORY_LIMIT = 50
|
||||
|
||||
|
||||
@router.get("/history", response_model=HistoryResponse)
|
||||
def list_history(
|
||||
conversation_id: str,
|
||||
traces: Annotated[TraceWriter, Depends(_trace_writer)],
|
||||
) -> HistoryResponse:
|
||||
"""Feature-List #25: past queries for one session, most recent first, so
|
||||
the UI can list them and let the user click one to re-run — not to
|
||||
replay the old answer verbatim, which isn't persisted (see
|
||||
`PostgresTraceRepository.list_by_conversation`'s docstring). Scoped
|
||||
strictly to `conversation_id`: this system has no auth anywhere, so an
|
||||
unscoped listing would leak every session's queries to every caller.
|
||||
An empty/missing `conversation_id` returns no rows rather than every
|
||||
trace in the database."""
|
||||
trimmed = conversation_id.strip()
|
||||
if not trimmed:
|
||||
return HistoryResponse(items=[])
|
||||
try:
|
||||
rows = traces.list_by_conversation(trimmed, limit=_HISTORY_LIMIT)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=503, detail="trace_store_unavailable") from exc
|
||||
return HistoryResponse(
|
||||
items=[
|
||||
HistoryItem(
|
||||
trace_id=row.trace_id,
|
||||
query=row.query,
|
||||
decision=row.decision,
|
||||
reason=row.reason,
|
||||
resolved_drug_id=row.resolved_drug_id,
|
||||
created_at=row.created_at.isoformat() if row.created_at else "",
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class SuggestResponse(BaseModel):
|
||||
suggestions: list[str]
|
||||
|
||||
@@ -181,6 +244,98 @@ def suggest_drugs(q: str, request: Request) -> SuggestResponse:
|
||||
return SuggestResponse(suggestions=agent.complete(q.strip()))
|
||||
|
||||
|
||||
class SectionListItem(BaseModel):
|
||||
section_key: str
|
||||
section_title: str
|
||||
|
||||
|
||||
class SectionListResponse(BaseModel):
|
||||
sections: list[SectionListItem]
|
||||
|
||||
|
||||
@router.get("/sections", response_model=SectionListResponse)
|
||||
def list_drug_sections(
|
||||
drug_id: str,
|
||||
retriever: Annotated[SectionListRetriever, Depends(_section_retriever)],
|
||||
) -> SectionListResponse:
|
||||
"""Feature-List #4: the real per-drug section checklist, not a generic
|
||||
fixed list — coverage genuinely varies (measured corpus-wide: 7 to 19
|
||||
sections per drug). No LLM/generation involved, a plain indexed-payload
|
||||
read, so an unknown or unresolved `drug_id` returns an empty list rather
|
||||
than a 404 — the caller (UI attribute picker) already knows which
|
||||
`drug_id` it resolved before calling this."""
|
||||
sections = retriever.list_sections(drug_id.strip())
|
||||
return SectionListResponse(
|
||||
sections=[
|
||||
SectionListItem(section_key=key, section_title=title)
|
||||
for key, title in sections
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class SectionTextPart(BaseModel):
|
||||
part_index: int | None = None
|
||||
text: str
|
||||
# True for a quarantined table/formula chunk — `text` is then the
|
||||
# chunker's own descriptor sentence ("bảng, trang N..."), not the
|
||||
# table's content; never a paraphrase, per the quarantine contract
|
||||
# (docs-legacy/adr/0006). The caller must not present this the same way
|
||||
# as a real verbatim excerpt.
|
||||
is_quarantined: bool
|
||||
printed_page_start: int | None = None
|
||||
printed_page_end: int | None = None
|
||||
physical_page: int | None = None
|
||||
|
||||
|
||||
class SectionTextResponse(BaseModel):
|
||||
drug_id: str
|
||||
section_key: str
|
||||
section_title: str | None = None
|
||||
parts: list[SectionTextPart]
|
||||
|
||||
|
||||
def _section_text_part(hit) -> SectionTextPart:
|
||||
doc = hit.document
|
||||
ref = doc.source_refs[0] if doc.source_refs else None
|
||||
printed_range = ref.printed_page_range if ref else None
|
||||
printed_start = printed_range[0] if printed_range else (ref.printed_page if ref else None)
|
||||
printed_end = printed_range[1] if printed_range else (ref.printed_page if ref else None)
|
||||
return SectionTextPart(
|
||||
part_index=doc.part_index,
|
||||
text=doc.text,
|
||||
is_quarantined=doc.requires_visual_check,
|
||||
printed_page_start=printed_start,
|
||||
printed_page_end=printed_end,
|
||||
physical_page=ref.physical_page if ref else None,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/section-text", response_model=SectionTextResponse)
|
||||
def get_section_text(
|
||||
drug_id: str,
|
||||
section_key: str,
|
||||
retriever: Annotated[SectionRetriever, Depends(_section_retriever)],
|
||||
) -> SectionTextResponse:
|
||||
"""Feature-List #23: the verbatim source of one section, on demand — no
|
||||
LLM/generation/entailment involved, so there is nothing to verify;
|
||||
`evidence_text` on a `/query` citation is the same underlying text but
|
||||
only for chunks the model actually cited, never a guaranteed whole
|
||||
section. `find_by_section` already returns every part in book order
|
||||
(never truncated), which this just joins into an ordered part list —
|
||||
curation of WHICH sections a UI offers this for (e.g. a "6 mục an
|
||||
toàn" default) is a client concern; this endpoint is generic to any
|
||||
real `section_key`, same as `find_by_section` itself."""
|
||||
hits = retriever.find_by_section(drug_id.strip(), section_key.strip())
|
||||
parts = [_section_text_part(hit) for hit in hits]
|
||||
section_title = hits[0].document.section_title if hits else None
|
||||
return SectionTextResponse(
|
||||
drug_id=drug_id,
|
||||
section_key=section_key,
|
||||
section_title=section_title,
|
||||
parts=parts,
|
||||
)
|
||||
|
||||
|
||||
def _map_citations(items) -> list[CitationResponse]:
|
||||
return [
|
||||
CitationResponse(
|
||||
@@ -343,6 +498,7 @@ def query_rag(
|
||||
citations=tuple(item.model_dump() for item in citations),
|
||||
correlation_id=correlation_id,
|
||||
otel_trace_id=otel_trace_id,
|
||||
conversation_id=payload.conversation_id,
|
||||
)
|
||||
except Exception:
|
||||
metrics.increment(TRACE_WRITE_FAILED)
|
||||
|
||||
Reference in New Issue
Block a user