fix/document-page-citations
#29
by sofhiaazzhr - opened
- src/agents/chat_handler.py +6 -5
- src/agents/chatbot.py +3 -1
- src/config/prompts/chatbot_system.md +5 -0
- src/retrieval/base.py +20 -0
- src/traceability/scratchpad.py +8 -1
src/agents/chat_handler.py
CHANGED
|
@@ -37,7 +37,7 @@ from typing import TYPE_CHECKING, Any
|
|
| 37 |
from langchain_core.messages import BaseMessage
|
| 38 |
|
| 39 |
from src.middlewares.logging import get_logger
|
| 40 |
-
from src.retrieval.base import RetrievalResult
|
| 41 |
from src.traceability import TraceabilityScratchpad, TraceabilityToolInvoker
|
| 42 |
|
| 43 |
from ..charts import chart_envelopes
|
|
@@ -1080,8 +1080,9 @@ def _render_document_context(results: list[RetrievalResult]) -> str:
|
|
| 1080 |
for r in results:
|
| 1081 |
data = r.metadata.get("data", {}) if isinstance(r.metadata, dict) else {}
|
| 1082 |
where = data.get("filename") or "document"
|
| 1083 |
-
|
| 1084 |
-
|
|
|
|
| 1085 |
text = r.content.strip()
|
| 1086 |
if len(text) > _DOC_CONTEXT_MAX_CHARS:
|
| 1087 |
text = text[:_DOC_CONTEXT_MAX_CHARS].rstrip() + " …"
|
|
@@ -1110,12 +1111,12 @@ def _normalize_chunks(raw: Any) -> list[DocumentChunk]:
|
|
| 1110 |
DocumentChunk(
|
| 1111 |
content=str(item.get("content", "")),
|
| 1112 |
filename=item.get("filename"),
|
| 1113 |
-
page_label=
|
| 1114 |
)
|
| 1115 |
)
|
| 1116 |
elif isinstance(item, RetrievalResult):
|
| 1117 |
data = item.metadata.get("data", {})
|
| 1118 |
-
page = data
|
| 1119 |
chunks.append(DocumentChunk(
|
| 1120 |
content=item.content,
|
| 1121 |
filename=data.get("filename"),
|
|
|
|
| 37 |
from langchain_core.messages import BaseMessage
|
| 38 |
|
| 39 |
from src.middlewares.logging import get_logger
|
| 40 |
+
from src.retrieval.base import RetrievalResult, citable_page
|
| 41 |
from src.traceability import TraceabilityScratchpad, TraceabilityToolInvoker
|
| 42 |
|
| 43 |
from ..charts import chart_envelopes
|
|
|
|
| 1080 |
for r in results:
|
| 1081 |
data = r.metadata.get("data", {}) if isinstance(r.metadata, dict) else {}
|
| 1082 |
where = data.get("filename") or "document"
|
| 1083 |
+
page = citable_page(data) # None for TXT (every chunk is "page 1")
|
| 1084 |
+
if page is not None:
|
| 1085 |
+
where += f", p. {page}"
|
| 1086 |
text = r.content.strip()
|
| 1087 |
if len(text) > _DOC_CONTEXT_MAX_CHARS:
|
| 1088 |
text = text[:_DOC_CONTEXT_MAX_CHARS].rstrip() + " …"
|
|
|
|
| 1111 |
DocumentChunk(
|
| 1112 |
content=str(item.get("content", "")),
|
| 1113 |
filename=item.get("filename"),
|
| 1114 |
+
page_label=citable_page(item),
|
| 1115 |
)
|
| 1116 |
)
|
| 1117 |
elif isinstance(item, RetrievalResult):
|
| 1118 |
data = item.metadata.get("data", {})
|
| 1119 |
+
page = citable_page(data) # None for TXT (every chunk is "page 1")
|
| 1120 |
chunks.append(DocumentChunk(
|
| 1121 |
content=item.content,
|
| 1122 |
filename=data.get("filename"),
|
src/agents/chatbot.py
CHANGED
|
@@ -93,7 +93,9 @@ def _format_document_chunks(chunks: list[DocumentChunk]) -> str:
|
|
| 93 |
return ""
|
| 94 |
blocks: list[str] = []
|
| 95 |
for c in chunks:
|
| 96 |
-
|
|
|
|
|
|
|
| 97 |
label = ", ".join(label_parts) if label_parts else "Unknown source"
|
| 98 |
blocks.append(f"[Source: {label}]\n{c.content}")
|
| 99 |
return "\n\n".join(blocks)
|
|
|
|
| 93 |
return ""
|
| 94 |
blocks: list[str] = []
|
| 95 |
for c in chunks:
|
| 96 |
+
# "p. N", not a bare number, so the model can cite the page (prompt rule 7).
|
| 97 |
+
page = f"p. {c.page_label}" if c.page_label else None
|
| 98 |
+
label_parts = [p for p in (c.filename, page) if p]
|
| 99 |
label = ", ".join(label_parts) if label_parts else "Unknown source"
|
| 100 |
blocks.append(f"[Source: {label}]\n{c.content}")
|
| 101 |
return "\n\n".join(blocks)
|
src/config/prompts/chatbot_system.md
CHANGED
|
@@ -21,6 +21,11 @@ You are a friendly, precise data assistant for a user who has registered databas
|
|
| 21 |
5. **Markdown is OK** for emphasis and small tables, but avoid heavy formatting (code fences, headers) unless the question genuinely calls for it.
|
| 22 |
6. **Figures: copy the marker, never invent one.** A document chunk may contain a figure marker like ``. When that figure helps answer the question, include its marker on its own line, copying the `asset://` id **character for character** — you may write a short caption inside the square brackets. Only use markers that appear in this turn's chunks; never write an image URL, never alter an id, and never mention "asset" or the id in your prose. The app turns the marker into the picture.
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
## Context shapes you'll see
|
| 25 |
|
| 26 |
- **Query result** — emitted when the user asked a data question that ran successfully. Contains `rows` (a list of dicts), `row_count`, the source/table that was queried, and any error string. If `error` is set, explain the failure plainly and suggest a next step.
|
|
|
|
| 21 |
5. **Markdown is OK** for emphasis and small tables, but avoid heavy formatting (code fences, headers) unless the question genuinely calls for it.
|
| 22 |
6. **Figures: copy the marker, never invent one.** A document chunk may contain a figure marker like ``. When that figure helps answer the question, include its marker on its own line, copying the `asset://` id **character for character** — you may write a short caption inside the square brackets. Only use markers that appear in this turn's chunks; never write an image URL, never alter an id, and never mention "asset" or the id in your prose. The app turns the marker into the picture.
|
| 23 |
|
| 24 |
+
7. **Cite the page for document answers — required.** Whenever your answer uses document chunks whose label has a page (`[Source: file.pdf, p. 7]`), the reader must be able to check it:
|
| 25 |
+
- put the page right after the point it supports, always written "(p. 7)" — in every reply language;
|
| 26 |
+
- **and** always make the very last line of the answer name the file and every page you used, always written "Source: file.pdf, p. 2, 7" — in every reply language. Both parts, every time: the inline pages do not replace the last line, and the last line does not replace the inline pages.
|
| 27 |
+
Cite only pages that appear in this turn's chunk labels; never guess one. Skip both when no chunk has a page.
|
| 28 |
+
|
| 29 |
## Context shapes you'll see
|
| 30 |
|
| 31 |
- **Query result** — emitted when the user asked a data question that ran successfully. Contains `rows` (a list of dicts), `row_count`, the source/table that was queried, and any error string. If `error` is set, explain the failure plainly and suggest a next step.
|
src/retrieval/base.py
CHANGED
|
@@ -13,6 +13,26 @@ class RetrievalResult:
|
|
| 13 |
source_type: str # "document" | "database"
|
| 14 |
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
class BaseRetriever(ABC):
|
| 17 |
@abstractmethod
|
| 18 |
async def retrieve(
|
|
|
|
| 13 |
source_type: str # "document" | "database"
|
| 14 |
|
| 15 |
|
| 16 |
+
# File types whose `page_label` is not a real page: TXT ingest stores every chunk as
|
| 17 |
+
# page 1 (`document/ingest_v2.chunks_from_text`), so citing it would always say "p. 1".
|
| 18 |
+
_NO_PAGE_TYPES = frozenset({"txt"})
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def citable_page(data: dict[str, Any]) -> Any:
|
| 22 |
+
"""The chunk's page to show the user (chip label, answer citation), or None.
|
| 23 |
+
|
| 24 |
+
`data` is a chunk's `metadata["data"]`. None when there is no page, or when the
|
| 25 |
+
file type has no real pages (TXT).
|
| 26 |
+
"""
|
| 27 |
+
file_type = str(data.get("file_type") or "").lower()
|
| 28 |
+
if not file_type:
|
| 29 |
+
name = str(data.get("filename") or "").lower()
|
| 30 |
+
file_type = name.rsplit(".", 1)[-1] if "." in name else ""
|
| 31 |
+
if file_type in _NO_PAGE_TYPES:
|
| 32 |
+
return None
|
| 33 |
+
return data.get("page_label")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
class BaseRetriever(ABC):
|
| 37 |
@abstractmethod
|
| 38 |
async def retrieve(
|
src/traceability/scratchpad.py
CHANGED
|
@@ -17,6 +17,7 @@ from typing import Any
|
|
| 17 |
from pydantic import BaseModel
|
| 18 |
|
| 19 |
from src.middlewares.logging import get_logger
|
|
|
|
| 20 |
|
| 21 |
from .schemas import (
|
| 22 |
KnowledgeUsed,
|
|
@@ -355,10 +356,16 @@ class TraceabilityScratchpad:
|
|
| 355 |
if key == (None, None) or key in self._doc_seen:
|
| 356 |
continue
|
| 357 |
self._doc_seen.add(key)
|
|
|
|
|
|
|
| 358 |
source: dict[str, Any] = {
|
| 359 |
"type": "document",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 360 |
"document_id": data.get("document_id"),
|
| 361 |
-
"filename":
|
| 362 |
"page_label": data.get("page_label"),
|
| 363 |
"query": _truncate(query),
|
| 364 |
}
|
|
|
|
| 17 |
from pydantic import BaseModel
|
| 18 |
|
| 19 |
from src.middlewares.logging import get_logger
|
| 20 |
+
from src.retrieval.base import citable_page
|
| 21 |
|
| 22 |
from .schemas import (
|
| 23 |
KnowledgeUsed,
|
|
|
|
| 356 |
if key == (None, None) or key in self._doc_seen:
|
| 357 |
continue
|
| 358 |
self._doc_seen.add(key)
|
| 359 |
+
filename = data.get("filename", "Unknown")
|
| 360 |
+
page = citable_page(data) # None for TXT (every chunk is "page 1")
|
| 361 |
source: dict[str, Any] = {
|
| 362 |
"type": "document",
|
| 363 |
+
# Display label. The FE citation chip reads `name` (then title/source/
|
| 364 |
+
# id) and otherwise falls back to "Source N", so without it the page
|
| 365 |
+
# never shows (2026-11-11 dry run).
|
| 366 |
+
"name": f"{filename}, p. {page}" if page is not None else filename,
|
| 367 |
"document_id": data.get("document_id"),
|
| 368 |
+
"filename": filename,
|
| 369 |
"page_label": data.get("page_label"),
|
| 370 |
"query": _truncate(query),
|
| 371 |
}
|