fix/document-page-citations

#29
by sofhiaazzhr - opened
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
- if data.get("page_label") is not None:
1084
- where += f", p. {data['page_label']}"
 
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=item.get("page_label"),
1114
  )
1115
  )
1116
  elif isinstance(item, RetrievalResult):
1117
  data = item.metadata.get("data", {})
1118
- page = data.get("page_label")
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
- label_parts = [p for p in (c.filename, c.page_label) if p]
 
 
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 `![](asset://3f2a9c0d1b4e5f60)`. 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 `![](asset://3f2a9c0d1b4e5f60)`. 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": data.get("filename", "Unknown"),
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
  }