Wire up query history: localStorage session persistence + sidebar UI

This commit is contained in:
2026-08-14 17:44:36 +07:00
parent 9be5819710
commit 057d4ed9dc
23 changed files with 1231 additions and 30 deletions
+156
View File
@@ -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)