diff --git a/AGENTS.md b/AGENTS.md index 6127f2d11..48a62326b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,3 +22,680 @@ feat/alice/add-document-preview fix/bob/chunk-position-range refactor/chris/extract-chunk-converter ``` + +--- + +## Project Structure + +```text +knowhereapi-main/ +├── apps/ +│ ├── api/ # FastAPI REST API (port 5005) +│ │ ├── app/ +│ │ │ ├── api/v1/routes/ # Endpoint handlers +│ │ │ ├── services/ # Business logic (auth, knowledge, billing) +│ │ │ └── repositories/ # Data access layer +│ │ └── main.py # Entrypoint, runs migrations on start +│ ├── worker/ # Celery worker for async document processing +│ │ ├── app/ +│ │ │ ├── services/document_parser/ # All parser modules +│ │ │ └── services/workload/ # Celery task handlers +│ │ └── worker.py # Celery entrypoint +│ ├── web/ # Frontend (separate repo: knowhere-dashboard) +│ └── docs/ # Internal documentation +├── packages/ +│ ├── shared-python/shared/ # Shared library (pip: knowhere-shared) +│ │ ├── models/database/ # SQLAlchemy ORM models +│ │ ├── models/schemas/ # Pydantic request/response schemas +│ │ ├── services/retrieval/ # Core retrieval engine +│ │ ├── services/chunks/ # DataFrame → ChunkPayload conversion +│ │ ├── services/ai/ # LLM prompt service & AI client +│ │ └── utils/ # Text, file, and chunk utilities +│ ├── sdk-python/ # Public Python SDK +│ ├── sdk-typescript/ # Public Node.js SDK +│ └── openapi-specs/ # OpenAPI spec definitions +└── deploy/ # Docker Compose & deployment scripts +``` + +--- + +## End-to-End Pipeline Overview + +```mermaid +flowchart TB + subgraph INGEST["① Document Ingestion (API)"] + Upload["POST /v1/documents"] --> Job["Create Job + S3 Upload"] + Job --> Queue["Celery Task Queue"] + end + + subgraph PARSE["② Document Parsing (Worker)"] + Queue --> Router["parse_service.checkerboard_inject_parse"] + Router --> Profiler["doc_profiler.profile_document"] + Profiler --> PDF["pdf_parser → MinerU"] + Profiler --> DOCX["doc_parser.parse_docx"] + Profiler --> PPTX["pptx_parser → iLoveAPI → PDF"] + Profiler --> XLSX["table_parser.parse_xlsx"] + Profiler --> MD["md_parser.parse_md"] + Profiler --> IMG["image_parser.parse_image"] + PDF --> DF["pd.DataFrame (ALL_DF_COLS)"] + DOCX --> DF + PPTX --> DF + XLSX --> DF + MD --> DF + IMG --> DF + end + + subgraph CONVERT["③ Chunk Conversion"] + DF --> Converter["dataframe_chunk_converter.dataframe_to_chunks"] + Converter --> Chunks["list[ChunkPayload]"] + end + + subgraph PUBLISH["④ Publication (shared)"] + Chunks --> Dedup["RetrievalPublicationService._dedup_chunks_by_content"] + Dedup --> DocState["publish_document_state → Documents/Sections/Chunks"] + DocState --> Graph["publish_document_graph → GraphNodes/GraphEdges"] + end + + subgraph RETRIEVE["⑤ Retrieval (shared)"] + Query["GET /v1/retrieval/query"] --> Pipeline["run_retrieval_query"] + Pipeline --> Channels["3-Channel BM25 (path/content/term)"] + Pipeline --> Agentic["RetrievalAgent.run (LLM-driven)"] + Channels --> RRF["RRF Fusion"] + Agentic --> Hydrate["hydrate_paths_to_rows"] + RRF --> Rank["_rank_candidates_by_path"] + Hydrate --> Rank + Rank --> Assemble["assemble_retrieval_results"] + Assemble --> Results["Cited Evidence Results"] + end +``` + +--- + +## Stage ②: Document Parsing Pipeline + +### Entry Point + +`apps/worker/app/services/document_parser/parse_service.py` → +`checkerboard_inject_parse()` + +This is the universal entry for all file types. It: + +1. **Profiles** the document via `doc_profiler.profile_document()` to detect + file type, page count, and special categories (e.g. `atlas`). +2. **Routes** to the appropriate parser based on file extension. +3. **Post-processes**: cleans up unreferenced images, compresses PNG→JPG. +4. Returns `(output_dir, parsed_df)` — the parsed DataFrame. + +### Parser Routing Table + +| Extension | Parser Module | Strategy | +|:---|:---|:---| +| `.pdf` | `pdf_parser.parse_pdfs` | MinerU API → `md_parser` → `layout_parser.pred_titles` | +| `.docx` | `doc_parser.parse_docx` + `convert_doc2dics` | OXML iteration → heading detection → hierarchical tree | +| `.doc` | `legacy_converter.doc_to_docx` → `.docx` pipeline | LibreOffice headless conversion first | +| `.pptx` | `pptx_parser.parse_pptx` | iLoveAPI PPTX→PDF → MinerU pipeline | +| `.xlsx` | `table_parser.parse_xlsx` | Sheet-by-sheet HTML table extraction | +| `.xls` | `legacy_converter.xls_to_xlsx` → `.xlsx` pipeline | LibreOffice conversion first | +| `.md` | `md_parser.parse_md` | Markdown heading parsing + LLM summaries | +| `.txt` | `txt_parser.parse_texts` → `md_parser` | Read lines then route to MD parser | +| `.png/.jpg` | `image_parser.parse_image` | VLM image description + OCR | +| `.fragment` | `fragment_parser.parse_fragment` | Raw text fragment ingestion | + +### Heading Detection: `layout_parser.pred_titles()` + +The core hierarchical recognition module. Determines heading levels using: + +1. **TOC-first**: If a DOCX TOC exists (`toc_parser.build_docx_toc_hierarchies`), + use it as ground truth for heading levels. +2. **Regex patterns**: Match numbered headings like `1.2.3`, `第X章`, `(一)`. +3. **LLM smart parse**: When `smart_title_parse=True`, send candidate headings + to the hierarchy model (`HIERARCHY_LLM_MODEL` or `NORMOL_MODEL`) for level + assignment. +4. **Font clustering (PDF)**: K-means on span heights from MinerU `layout.json` + to group headings into 5 discrete tiers. + +### DOCX Parsing Deep Dive: `doc_parser.py` + +```mermaid +flowchart LR + A[load_file_bytes] --> B[iter_block_items] + B --> C{Element Type} + C -->|CT_P| D[Paragraph + Images] + C -->|CT_Tbl| E[Table + Cell Images] + C -->|sdt| F[TOC Detection] + D --> G[pred_titles → heading levels] + G --> H[Build hierarchical tree] + E --> I[table2html → HTML] + H --> J[get_leaf_dics → flatten] + J --> K[postprocess_leaf_dics → LLM summaries] + K --> L[convert_doc2dics → DataFrame] +``` + +Key logic in `parse_docx()`: + +- **`iter_block_items()`**: Iterates OXML body elements, yielding + `(ele_num, content, label, meta)` tuples. Labels: `PTXT`, `TABLE`, + `IMAGE`, `TOC-AREA`. +- **Heading stack**: Maintains `headings_stack` with `{heading, content[], level}` + dicts. New headings pop the stack to their parent level. +- **Image dedup**: Uses `perceptual_hash()` for document-level visual dedup. + Cached in `_seen_images` dict. +- **Table handling**: `table2html()` converts python-docx Table to HTML with + accurate `rowspan`/`colspan` via direct OXML inspection. + +### PDF Parsing: MinerU Pipeline + +```mermaid +flowchart LR + PDF[pdf_parser] --> MinerU[MinerU Cloud API] + MinerU --> MDFile[Markdown + layout.json] + MDFile --> MDParser[md_parser.parse_md] + MDParser --> EvalHeadings[eval_md_headings + layout.json] + EvalHeadings --> PredTitles[layout_parser.pred_titles] + PredTitles --> Chunks[Hierarchical Chunks] +``` + +### LLM Models Used + +| Task | Config Key | Default Model | +|:---|:---|:---| +| Text/table summarization | `NORMOL_MODEL` | `deepseek-chat` | +| Heading hierarchy recognition | `HIERARCHY_LLM_MODEL` | Falls back to `NORMOL_MODEL` | +| Image description (VLM) | `IMAGE_MODEL` | `qwen3.5-flash` | +| Image OCR / Q&A | `IMAGE_MODEL_MAX` | `qwen3.5-flash` | +| Atlas classification | VLM via `atlas_classifier` | `IMAGE_MODEL` | + +--- + +## Persisted Knowledge Base Schema (On-Disk Output) + +After parsing and chunk conversion, results are persisted to `~/.knowhere/{kb_name}/`. +This on-disk structure is the **authoritative persisted format** — the intermediate +DataFrame is an internal detail. Below is the complete schema. + +### KB-Level Directory Layout + +```text +~/.knowhere/{kb_name}/ +├── knowledge_graph.json # KB-wide graph: file metadata + cross-doc edges +├── chunk_stats.json # Per-chunk retrieval hit analytics {chunk_id → stats} +├── {source_file_name}/ # One directory per ingested document +│ ├── chunks.json # All parsed chunks for this document +│ ├── doc_nav.json # Hierarchical navigation tree for agentic retrieval +│ ├── manifest.json # Parse metadata + full heading hierarchy +│ ├── {source_file_name}.zip # Archived original + parsed assets +│ ├── images/ # Extracted image assets (PNG/JPG) +│ ├── tables/ # Extracted table assets (HTML) +│ ├── preds_3_llm_base.csv # Debug: heading predictions (base LLM pass) +│ ├── preds_4_llm_final.csv # Debug: heading predictions (final LLM pass) +│ ├── preds_5_final_output.csv # Debug: final parser DataFrame output +│ └── toc_hierarchies.json # Debug: extracted TOC structure (DOCX only) +``` + +### `knowledge_graph.json` — KB-Wide Graph + +```json +{ + "version": "2.0", + "kb_id": "test_kb", + "stats": { "total_files": 3, "total_chunks": 364, "total_cross_file_edges": 0 }, + "files": { + "AI_Security_Report.docx": { + "chunks_count": 155, + "types": { "image": 13, "table": 1, "text": 141 }, + "top_keywords": ["model", "security", "ai", "operations", "artificial_intelligence"], + "top_summary": "This document includes: Legal Notice, Foreword, 1. Overview, ...", + "importance": 0.3, + "created_at": "2026-05-09T09:14:12.422208+00:00" + } + }, + "edges": [] +} +``` + +| Field | Description | +|:---|:---| +| `files.{name}.top_keywords` | TF-IDF top keywords across all chunks (used for cross-doc edge scoring) | +| `files.{name}.top_summary` | Auto-generated outline of top-level headings (injected by `load_nav_top_summary()`) | +| `files.{name}.importance` | Base importance score (feeds `compute_importance_score()` in ranking) | +| `edges[]` | Cross-document edges with `{source, target, weight, shared_keywords}` when keyword overlap ≥ 0.8 | + +### `chunk_stats.json` — Retrieval Hit Analytics + +```json +{ + "2e2beffc-90b2-5429-8ee7-3c49260a1204": { + "hit_count": 0, + "first_hit": null, + "last_hit": null, + "created_at": "2026-05-09T09:14:12.423011+00:00" + } +} +``` + +Keyed by `chunk_id`. `hit_count` and `last_hit` feed into `importance_norm_score` +for retrieval ranking boost. + +### `chunks.json` — Per-Document Chunk Records + +The core persisted data. Contains `{"chunks": [...]}` — an ordered array of +chunk objects. Three chunk types exist: + +#### Text Chunk + +```json +{ + "chunk_id": "d88e4c47-3c48-5bdf-b849-693c00453021", + "type": "text", + "content": "AI Security Report\n\nThe image displays a tech theme...\n[images/image-1 ai_model.png]\n", + "path": "test_kb/AI_Security_Report.docx/1. Overview/1.1 Key Findings", + "metadata": { + "length": 220, + "summary": "", + "page_nums": [], + "tokens": ["model", "technology", "market", "research", "report"], + "keywords": [], + "connect_to": [ + { + "target": "2e2beffc-90b2-5429-8ee7-3c49260a1204", + "relation": "embeds", + "ref": "[images/image-1 ai_model.png]", + "position": { "start": 109, "end": 135 } + } + ] + } +} +``` + +#### Image Chunk + +```json +{ + "chunk_id": "2e2beffc-90b2-5429-8ee7-3c49260a1204", + "type": "image", + "content": "\nThe image displays a tech theme...\n[images/image-1 ai_model.png]\n", + "path": "images/image-1 ai_model.png", + "metadata": { + "length": 121, + "summary": "image-1\nThe image displays a tech theme...", + "page_nums": [], + "file_path": "images/image-1 ai_model.png", + "keywords": [], + "tokens": [] + } +} +``` + +#### Table Chunk + +```json +{ + "chunk_id": "a5c3d644-479a-51f9-9a54-ff6789c1f6e8", + "type": "table", + "content": "...
Architecture LayerAI Capabilities
", + "path": "tables/table-1 ai_architecture.html", + "metadata": { + "length": 488, + "summary": "table-1\nThe table shows the architecture layers...", + "page_nums": [], + "file_path": "tables/table-1 ai_architecture.html", + "keywords": ["AI_Capabilities", "Security_Engine", "Intelligent_Collaboration"], + "tokens": [] + } +} +``` + +#### Chunk Field Reference + +| Field | Type | Description | +|:---|:---|:---| +| `chunk_id` | `str` | Deterministic UUID5 hash from content (`gen_str_codes`) — enables cross-doc dedup | +| `type` | `str` | `"text"` / `"image"` / `"table"` | +| `content` | `str` | Raw text, VLM description + asset ref, or HTML `` | +| `path` | `str` | Hierarchical path: `{kb}/{file}/{section1}/{section2}/...` for text; `images/...` or `tables/...` for assets | +| `metadata.length` | `int` | Character count of content | +| `metadata.summary` | `str` | LLM summary (images/tables: `"image-N\n{description}"`) | +| `metadata.tokens` | `list[str]` | Pre-tokenized Chinese terms for BM25 retrieval | +| `metadata.keywords` | `list[str]` | LLM-extracted keywords (semicolon-split from DataFrame) | +| `metadata.page_nums` | `list[int]` | Source page numbers (PDF only) | +| `metadata.file_path` | `str` | Relative asset path for images/tables | +| `metadata.connect_to` | `list[ConnectionValue]` | Cross-chunk references (text→image/table embeddings) | +| `metadata.connect_to[].target` | `str` | Target chunk_id | +| `metadata.connect_to[].relation` | `str` | `"embeds"` (inline asset) or `"related"` | +| `metadata.connect_to[].ref` | `str` | Original reference string: `"[images/image-1.png]"` | +| `metadata.connect_to[].position` | `{start, end}` | Character offset of the reference in content | + +### `doc_nav.json` — Hierarchical Navigation Tree + +Used by agentic retrieval for 2-level section browsing. Structure: + +```json +{ + "version": "1.0", + "file_name": "AI_Security_Report.docx", + "stats": { "total_chunks": 155, "text_chunks": 141, "image_chunks": 13, "table_chunks": 1, "max_depth": 4 }, + "sections": [ + { + "title": "1. Overview", + "path": "test_kb/AI_Security_Report.docx/1. Overview", + "level": 1, + "summary": "This section covers: 1.1 Key Findings, 1.2 Recommendations", + "chunk_count": 6, + "children": [ + { + "title": "1.1 Key Findings", + "path": "test_kb/.../1. Overview/1.1 Key Findings", + "level": 2, + "summary": "This section covers: Supply Side Perspective, Demand Side Perspective...", + "chunk_count": 4, + "children": [...] + } + ] + } + ], + "resources": { + "images": [{ "path": "images/image-1 ai_model.png", "summary": "image-1 The image displays a tech theme..." }], + "tables": [{ "path": "tables/table-1 ai_architecture.html", "summary": "table-1 The table shows..." }] + } +} +``` + +### `manifest.json` — Parse Metadata & Heading Hierarchy + +```json +{ + "version": "2.0", + "job_id": "AI_Security_Report.docx", + "source_file_name": "AI_Security_Report.docx", + "processing_date": "2026-05-09T07:46:00.395048Z", + "statistics": { "total_chunks": 155, "text_chunks": 141, "image_chunks": 13, "table_chunks": 1 }, + "HIERARCHY": { + "Root": {}, + "1. Overview": { + "1.1 Key Findings": { "Supply Side Perspective": {}, "Demand Side Perspective": {} }, + "1.2 Recommendations": {} + }, + "2. History of AI in Cybersecurity": { "...": {} } + } +} +``` + +The `HIERARCHY` field is a nested dict representing the full heading tree +discovered by `layout_parser.pred_titles()`. Each key is a heading title; +its value is a dict of child headings (empty `{}` for leaf nodes). + +### Intermediate DataFrame (`ALL_DF_COLS`) + +Parsers internally produce a `pd.DataFrame` with columns: +`content, path, type, length, keywords, summary, know_id, tokens, connectto, addtime, page_nums`. +This is converted to `ChunkPayload` objects via `dataframe_chunk_converter.dataframe_to_chunks()` +before persisting to `chunks.json`. The DataFrame is a transient internal format; +debug CSVs (`preds_*.csv`) are saved alongside for troubleshooting. + +--- + +## Stage ④: Publication — Database Schema + +### Core Tables + +#### `documents` + +| Column | Type | Description | +|:---|:---|:---| +| `document_id` | `String(36)` PK | `doc_{uuid_hex[:12]}` | +| `user_id` | `Text` FK → `user.id` | Owner | +| `namespace` | `String(255)` | Isolation scope (default: `"default"`) | +| `status` | `String(32)` | `active` / `archived` | +| `current_job_result_id` | `String(36)` FK | Points to active revision | +| `source_file_name` | `Text` | Original filename | + +#### `document_sections` + +| Column | Type | Description | +|:---|:---|:---| +| `section_id` | `String(36)` PK | `sec_{uuid_hex[:12]}` | +| `document_id` | FK → `documents` | Parent document | +| `job_result_id` | FK → `job_results` | Revision | +| `parent_section_id` | FK → self | Parent section (tree structure) | +| `section_path` | `Text` UNIQUE(doc+rev+path) | `"file.docx / Chapter 1 / Section 1.1"` | +| `section_title` | `Text` | Heading text | +| `section_level` | `Integer` | Depth in hierarchy (1-based) | +| `summary` | `Text` | Section summary | +| `sort_order` | `Integer` | Display order | + +#### `document_chunks` + +| Column | Type | Description | +|:---|:---|:---| +| `id` | `String(36)` PK | `dchk_{uuid_hex[:12]}` | +| `chunk_id` | `String(64)` | Content hash (deterministic dedup key) | +| `document_id` | FK → `documents` | Parent document | +| `section_id` | FK → `document_sections` | Parent section | +| `chunk_type` | `String(64)` | `text` / `image` / `table` | +| `content` | `Text` | Chunk content (text/HTML) | +| `content_search_text` | `Text` | Pre-tokenized for BM25 content channel | +| `path_search_text` | `Text` | Pre-tokenized for BM25 path channel | +| `term_search_text` | `Text` | Pre-tokenized for term/grep channel | +| `content_search_tsv` | `TSVECTOR` (computed) | PostgreSQL GIN index for full-text | +| `path_search_tsv` | `TSVECTOR` (computed) | PostgreSQL GIN index for path | +| `source_chunk_path` | `Text` | Original parser path | +| `file_path` | `Text` | Asset reference (`images/x.jpg`) | +| `chunk_metadata` | `JSON` | Keywords, tokens, connect_to, etc. | +| `sort_order` | `Integer` | Display order | + +#### `graph_nodes` + +| Column | Type | Description | +|:---|:---|:---| +| `node_id` | `String(128)` PK | `doc:{document_id}` | +| `node_kind` | `String(32)` | `document` (only doc-level nodes) | +| `owner_document_id` | FK → `documents` | Source document | +| `properties` | `JSON` | `{source_file_name, top_keywords, chunks_count, types, top_summary}` | + +#### `graph_edges` + +| Column | Type | Description | +|:---|:---|:---| +| `edge_id` | `String(160)` PK | `related:{doc_a}<->{doc_b}` (sorted pair) | +| `edge_kind` | `String(32)` | `related` | +| `source_node_id` / `target_node_id` | FK → `graph_nodes` | Connected docs | +| `weight` | `Float` | Keyword overlap score (≥ 0.8 threshold) | +| `properties` | `JSON` | `{shared_keywords, connection_count}` | +| `is_directed` | `Boolean` | Always `False` for related edges | + +### Publication Logic + +`RetrievalPublicationService.publish_document_state()`: + +1. **Dedup**: Cross-document content-hash dedup via `_dedup_chunks_by_content`. +2. **Section tree**: Builds `DocumentSection` tree from chunk paths, creating + ancestor sections top-down. +3. **Search text**: Generates 3 search text channels per chunk: + - `content_search_text`: Tokenized content + summary + - `path_search_text`: Tokenized file name + section path + summary + - `term_search_text`: Raw content + path for substring grep +4. **Graph**: `DocumentGraphService.publish_document_graph()` creates doc-level + `GraphNode` with TF-IDF keywords, then keyword-overlap `GraphEdge`s to peer + documents (min 3 shared keywords, score ≥ 0.8). + +--- + +## Stage ⑤: Retrieval Engine + +### Entry Point + +`shared/services/retrieval/app_service.py` → `run_retrieval_query()` + +### Two Retrieval Modes + +The system supports two modes controlled by `RETRIEVAL_AGENTIC_ENABLED`: + +#### Legacy Mode (3-Channel RRF) + +```mermaid +flowchart LR + Q[Query] --> P[Path Channel: BM25 on path_search_text] + Q --> C[Content Channel: BM25 on content_search_text] + Q --> T[Term Channel: substring on term_search_text] + P --> RRF["RRF Fusion (k=60)"] + C --> RRF + T --> RRF + RRF --> Graph[Legacy Graph Routing] + Graph --> Rank[Dual-priority ranking] + Rank --> Assemble[assemble_retrieval_results] +``` + +**Channel weights** (default): path=1.0, content=2.0, term=1.5 + +**RRF formula**: `score = weight / (k + rank + 1)` per channel, summed across channels. + +#### Agentic Mode (LLM-driven Navigation) + +#### Agentic Mode (LLM-driven Navigation) + +The agentic pipeline uses a deterministic multi-phase orchestration engine: + +**Phase 1: Discovery + Document Selection** +- **Bottom Discovery**: Always runs first. Executes a 3-channel RRF keyword search across the entire Knowledge Base, returning top high-relevance chunks and their parent documents (`discovery_auto`). +- **KG Document Select**: The LLM analyzes the KB-wide overview (from `knowledge_graph.json`) and selects highly relevant documents. +- *Merge Strategy*: Documents found by Bottom Discovery but omitted by the LLM are automatically appended to the selected documents list to ensure no blind spots. + +**Phase 2: Per-Document Navigation & Discovery Merging** +For each selected document, the agent performs a constrained Breadth-First Search (BFS): +1. **Scope Navigation**: The document's section tree is dynamically rendered to the LLM. + - *Path-Based Hierarchy*: Child nodes are strictly filtered using structural path prefixes (e.g., `child_path.startswith(parent_path + ' / ')`) to maintain structural integrity and eliminate L2 duplicate rendering. + - *Visual Constraints*: Actionable drill-down paths are explicitly prefixed with `[SELECT]` tags. The LLM system prompt tightly constrains the model to only pick paths with this tag, preventing redundant re-selection of the current scope. +2. **Discovery Select**: The LLM reviews the specific paths flagged by Phase 1's Bottom Discovery for the current document. Selected discovery paths have their leaf chunks merged directly into the BFS document tree. + +**Phase 3: Verdict & Revision** +The combined document tree (BFS Navigation + Discovery) is rendered as unified evidence. The tree naturally displays structural context (outlines) alongside hydrated chunk rows (for selected leaf paths). The LLM attempts to answer the user's query: +- `DONE`: Evidence is sufficient (or partially covers the query), exit and return final results. +- `NOT_FOUND`: Evidence lacks sufficient information. Discard current evidence and trigger another revision round with a generated hint (max 2 rounds). + +### Tree Rendering & Hydration + +Unlike legacy retrieval which relied on static `hydrate_mode` tags, hydration is now determined dynamically by the `DocTreeNode` structure: +- **Structural Context (Outlines)**: Sections not drilled into are simply rendered as structural outlines (`title` + `summary`) to guide the LLM. +- **Leaf Content (Hydration)**: Sections that the LLM explicitly selects for drill-down have their raw chunks (`text`, `image`, `table`) fully hydrated into the `leaf_content` of the tree. +- **Multi-Modal Inline Embedding**: During hydration, connected inline assets (images/tables) are natively resolved and embedded directly into the text chunk content, supporting multi-modal LLM processing without brittle string-replacement placeholders. + +**`_rank_candidates_by_path()`** — Dual-priority ranking: + +- When agent results exist: agent_score is primary, discovery_score is tiebreaker +- Rows with agent_score=0 are demoted to fallback pool +- Sort key: `(agent_score, discovery_score, dual_hit_flag, importance_norm_score)` + +### Result Assembly + +`assemble_retrieval_results()`: + +1. Filters by `exclude_document_ids` and `exclude_sections` +2. Filters by `allowed_chunk_types` (data_type parameter) +3. Hydrates `connect_to` targets (related table chunks inlined into text) +4. Cleans asset path references from content +5. Attaches citation: `{document_id, chunk_id, source_file_name, section_path}` + +### Small KB Optimization + +When `total_chunks <= top_k`, skips the full pipeline and returns all chunks +directly (router: `small_kb_all`). + +### Caching + +Results are cached per `(user_id, namespace, query, top_k, filters)` via +`cache_service`. Cache version is checked before execution; cache is written +after successful retrieval. + +--- + +## Analytics Tables + +#### `retrieval_hit_stats` + +Tracks per-chunk and per-document retrieval usage. `hit_count` and `last_hit_at` +feed into `compute_importance_score()` for ranking boost. + +#### `retrieval_runs` / `retrieval_steps` + +Append-only agentic retrieval analytics. One `retrieval_runs` row per query, +with child `retrieval_steps` rows recording each agent action, its input/output, +latency, and token usage. + +--- + +## Key Implementation Patterns + +### Deterministic Chunk IDs + +`know_id = gen_str_codes(pure_text)` — SHA-based hash of text content only +(excludes image/table asset refs). This enables cross-document dedup: +identical text in different uploads produces the same `chunk_id`. + +### Plan-then-Act DOM Mutation + +When splitting tables or modifying document structure: +1. **Pass 1 (Investigate)**: Collect mutation targets into a static plan +2. **Pass 2 (Execute)**: Apply mutations in **reverse order** to avoid index shifting + +### Image Dedup: Perceptual Hash + +`perceptual_hash()` computes a visual fingerprint. Images with identical +hashes are deduplicated within a document, with cached metadata reused. + +### Asset Lifecycle (Deterministic UIDs) + +`IMAGE_[hash(content+seq)]_IMAGE` — identical images at different positions +receive unique IDs. Context chaining prevention scans backward past binary +identifiers to find the nearest valid text. + +### LLM Constraints + +- **DeepSeek JSON mode**: Requires the word "json" in the prompt when + `response_format` is `json_object` +- **Streaming robustness**: Concatenate `delta.content` only if `not None` +- **Token pool rotation**: Ali API keys support per-token RPM limits, + cooldown, and inline retry with next available token + +--- + +## Development & Debugging + +### Local Setup + +```bash +uv sync --all-packages +cp apps/api/.env.example apps/api/.env +cp apps/worker/.env.example apps/worker/.env +./deploy/local-dev/start-dev.sh # PostgreSQL, Redis, LocalStack +cd apps/api && uv run main.py # API on :5005 +cd apps/worker && uv run worker.py # Celery worker +``` + +### Debug Scripts (Worker) + +| Script | Purpose | +|:---|:---| +| `debug_parse.py` | End-to-end parsing with `MockRedis`, `LOCAL_DEBUG=1` | +| `debug_hierarchy_llm.py` | Test heading recognition LLM calls | +| `debug_agentic_e2e.py` | End-to-end agentic retrieval test | +| `debug_profiler.py` | Document profiler testing | +| `debug_toc_detection.py` | TOC detection and hierarchy building | + +### Quality Checks + +```bash +make lint # Ruff lint +make lint-fix # Auto-fix safe issues +make typecheck # Pyright across api, worker, shared +make check # Both lint + typecheck +``` + +### Local Endpoints + +| Service | URL | +|:---|:---| +| API | `http://localhost:5005` | +| OpenAPI docs | `http://localhost:5005/docs` | +| PostgreSQL | `localhost:5432` | +| Redis | `localhost:6379` | +| LocalStack (S3) | `http://localhost:4566` | diff --git a/apps/api/app/mcp/retrieval_server.py b/apps/api/app/mcp/retrieval_server.py index 35c29aa9a..2121cdb4e 100644 --- a/apps/api/app/mcp/retrieval_server.py +++ b/apps/api/app/mcp/retrieval_server.py @@ -66,10 +66,15 @@ def to_mcp_query_response(response: dict[str, Any]) -> dict[str, Any]: result["asset_url"] = row["asset_url"] results.append(result) - return { + mcp_response: dict[str, Any] = { "query": response.get("query"), "results": results, } + # Forward evidence_text for agentic mode + if response.get("evidence_text") is not None: + mcp_response["evidence_text"] = response["evidence_text"] + + return mcp_response async def resolve_mcp_user_id(*, ctx: Context | None, db: AsyncSession) -> str: diff --git a/apps/api/tests/contract/test_billing_contract.py b/apps/api/tests/contract/test_billing_contract.py index b62fd941d..75986d40e 100644 --- a/apps/api/tests/contract/test_billing_contract.py +++ b/apps/api/tests/contract/test_billing_contract.py @@ -1,3 +1,4 @@ +import asyncio import importlib import json from collections.abc import Callable @@ -123,6 +124,58 @@ async def test_should_initialize_missing_user_balance_during_tier_lookup( } +@pytest.mark.asyncio +async def test_should_initialize_missing_user_balance_once_for_concurrent_requests( + api_client_factory: Callable[[], AbstractAsyncContextManager[AsyncClient]], +) -> None: + user_id = f"contract-concurrent-balance-{uuid4().hex[:12]}" + api_key = f"sk_contract_{uuid4().hex[:24]}" + + async with api_client_factory() as api_client: + await ContractDatabase.insert_user(user_id=user_id) + await _insert_api_key_for_user(user_id, api_key) + api_client.headers.update({"Authorization": f"Bearer {api_key}"}) + + responses = await asyncio.gather( + *[api_client.get("/api/v1/billing/credits") for _ in range(8)] + ) + + balance_row = await ContractDatabase.fetch_one( + """ + SELECT credits_balance, user_tier + FROM user_balances + WHERE user_id = :user_id + """, + {"user_id": user_id}, + ) + transaction_count_row = await ContractDatabase.fetch_one( + """ + SELECT COUNT(*) AS count + FROM credits_transactions + WHERE user_id = :user_id + AND transaction_type = 'initial_grant' + """, + {"user_id": user_id}, + ) + payment_count_row = await ContractDatabase.fetch_one( + """ + SELECT COUNT(*) AS count + FROM payment_records + WHERE user_id = :user_id + AND payment_type = 'system_grant' + """, + {"user_id": user_id}, + ) + + assert all(response.status_code == 200 for response in responses) + assert all( + response.json() == {"credits_balance": 5.0} for response in responses + ) + assert balance_row == {"credits_balance": 5_000_000, "user_tier": "free"} + assert transaction_count_row == {"count": 1} + assert payment_count_row == {"count": 1} + + @pytest.mark.asyncio async def test_should_not_register_billing_routes_when_billing_is_disabled( monkeypatch: MonkeyPatch, diff --git a/apps/docs/test_github_flow.md b/apps/docs/test_github_flow.md new file mode 100644 index 000000000..84fd82cd5 --- /dev/null +++ b/apps/docs/test_github_flow.md @@ -0,0 +1,4 @@ +# GitHub Flow Test + +This is a test file to verify the GitHub workflow simulation (Issue -> Branch -> PR -> Merge). +It is safe to ignore or delete this file later. diff --git a/apps/worker/tests/contract/test_parse_task_contract.py b/apps/worker/tests/contract/test_parse_task_contract.py index 3379b8613..a5c0aee0a 100644 --- a/apps/worker/tests/contract/test_parse_task_contract.py +++ b/apps/worker/tests/contract/test_parse_task_contract.py @@ -3,7 +3,9 @@ import json import shutil import zipfile +from concurrent.futures import ThreadPoolExecutor from pathlib import Path +from threading import Barrier from types import SimpleNamespace from typing import Any from uuid import uuid4 @@ -566,6 +568,256 @@ def upload(self, *, job_id: str, result_dir: str, zip_file_path: str) -> Any: ] +def test_should_initialize_billing_once_for_concurrent_parse_tasks( + worker_contract_environment: None, + monkeypatch: MonkeyPatch, + tmp_path: Path, +) -> None: + ( + kb_tasks, + parse_service, + sync_storage_service, + engine, + sync_job_info_service_cls, + sync_job_metadata_service_cls, + sync_redis_service_factory, + ) = _load_parse_task_modules() + + user_id: str = f"worker-concurrent-user-{uuid4().hex[:12]}" + job_ids: list[str] = [f"job_cb_{index}_{uuid4().hex[:12]}" for index in range(2)] + source_file_name: str = "contract-concurrent.pdf" + s3_keys: dict[str, str] = { + job_id: f"uploads/{job_id}.pdf" for job_id in job_ids + } + job_metadata_by_id: dict[str, dict[str, Any]] = { + job_id: _build_pending_file_job_metadata(source_file_name) + for job_id in job_ids + } + + with engine.begin() as connection: + insert_contract_user(connection, user_id=user_id) + for job_id in job_ids: + insert_contract_job( + connection, + job_id=job_id, + user_id=user_id, + status="pending", + source_type="file", + s3_key=s3_keys[job_id], + webhook_enabled=False, + job_metadata=job_metadata_by_id[job_id], + billing_status="pending", + ) + + redis_service = sync_redis_service_factory.get_service() + for job_id in job_ids: + _save_worker_task_cache( + job_id=job_id, + user_id=user_id, + s3_key=s3_keys[job_id], + metadata=job_metadata_by_id[job_id], + sync_job_info_service_cls=sync_job_info_service_cls, + sync_job_metadata_service_cls=sync_job_metadata_service_cls, + sync_redis_service_factory=sync_redis_service_factory, + ) + + _bind_parse_task_to_current_module(monkeypatch, kb_tasks=kb_tasks) + monkeypatch.setattr(kb_tasks.settings, "TMP_PATH", str(tmp_path)) + monkeypatch.setattr(kb_tasks.settings, "BILLING_ENABLED", True) + + def fake_verify_s3_file_exists(storage_key: str) -> dict[str, Any]: + return { + "exists": storage_key in s3_keys.values(), + "size": _SAMPLE_PDF_PATH.stat().st_size, + } + + def fake_generate_download_url(storage_key: str, bucket: str | None) -> dict[str, str]: + return {"download_url": f"https://example.test/{storage_key}"} + + def fake_download_s3_file_to_temp( + file_url: str, file_ext: str, temp_dir: str + ) -> str: + assert file_ext == ".pdf" + downloaded_path = Path(temp_dir) / f"downloaded{file_ext}" + shutil.copy2(_SAMPLE_PDF_PATH, downloaded_path) + return str(downloaded_path) + + billing_start_barrier = Barrier(len(job_ids)) + + def fake_estimate_page_count(file_path: str) -> int: + billing_start_barrier.wait(timeout=10) + return 1 + + def fake_checkerboard_inject_parse(**kwargs: Any) -> tuple[str, pd.DataFrame]: + output_dir = ( + Path(str(kwargs["output_dir"])) + / str(kwargs["kb_dir"]) + / str(kwargs["internal_output_filename"]) + ) + output_dir.mkdir(parents=True, exist_ok=True) + (output_dir / "full.md").write_text("body", encoding="utf-8") + + file_root = str(kwargs["internal_output_filename"]) + parsed_rows: list[dict[str, Any]] = [ + { + "content": "chunk body", + "path": f"Default_Root/{file_root}/Section/Point", + "type": "text", + "length": 10, + "keywords": "", + "summary": "", + "know_id": f"{kwargs['job_id']}-chunk-1", + "tokens": "", + "connectto": "", + "addtime": "now", + "page_nums": "1", + } + ] + return str(output_dir), pd.DataFrame(parsed_rows) + + class FakeResultStorage: + def upload(self, *, job_id: str, result_dir: str, zip_file_path: str) -> Any: + return SimpleNamespace( + zip_key=f"results/{job_id}.zip", + raw_prefix=f"results/{job_id}/", + raw_files={}, + ) + + monkeypatch.setattr(kb_tasks, "verify_s3_file_exists", fake_verify_s3_file_exists) + monkeypatch.setattr( + sync_storage_service, + "verify_s3_file_exists", + fake_verify_s3_file_exists, + ) + monkeypatch.setattr(kb_tasks, "generate_download_url", fake_generate_download_url) + monkeypatch.setattr( + sync_storage_service, + "generate_download_url", + fake_generate_download_url, + ) + monkeypatch.setattr(kb_tasks, "download_s3_file_to_temp", fake_download_s3_file_to_temp) + monkeypatch.setattr(kb_tasks.PageEstimator, "estimate", fake_estimate_page_count) + monkeypatch.setattr(parse_service, "checkerboard_inject_parse", fake_checkerboard_inject_parse) + monkeypatch.setattr(kb_tasks, "get_result_storage", lambda: FakeResultStorage()) + + def run_parse_task(job_id: str) -> dict[str, Any]: + return dict(kb_tasks.parse_task.run(job_id, user_id, "kb_management")) + + with ThreadPoolExecutor(max_workers=len(job_ids)) as executor: + results = list(executor.map(run_parse_task, job_ids)) + + expected_credits_charged = int(kb_tasks.settings.MICRO_DOLLARS_PER_PAGE) + expected_initial_balance = ( + int(kb_tasks.settings.FREE_PLAN_INITIAL_CREDITS) * 1_000_000 + ) + + with engine.begin() as connection: + job_rows = list( + connection.execute( + text( + """ + SELECT job_id, status, billing_status, page_count, credits_charged + FROM jobs + WHERE job_id = ANY(:job_ids) + ORDER BY job_id + """ + ), + {"job_ids": job_ids}, + ) + .mappings() + .all() + ) + balance_row = ( + connection.execute( + text( + """ + SELECT credits_balance + FROM user_balances + WHERE user_id = :user_id + """ + ), + {"user_id": user_id}, + ) + .mappings() + .one() + ) + transaction_rows = list( + connection.execute( + text( + """ + SELECT transaction_type, COUNT(*) AS count + FROM credits_transactions + WHERE user_id = :user_id + GROUP BY transaction_type + ORDER BY transaction_type + """ + ), + {"user_id": user_id}, + ) + .mappings() + .all() + ) + payment_count_row = ( + connection.execute( + text( + """ + SELECT COUNT(*) AS count + FROM payment_records + WHERE user_id = :user_id + AND payment_type = 'system_grant' + """ + ), + {"user_id": user_id}, + ) + .mappings() + .one() + ) + + metadata_by_job_id = { + job_id: sync_job_metadata_service_cls(redis_service).get_metadata(job_id) + for job_id in job_ids + } + result_job_ids = {str(result["job_id"]) for result in results} + transaction_counts = { + str(row["transaction_type"]): int(row["count"]) for row in transaction_rows + } + + assert result_job_ids == set(job_ids) + assert all(result["status"] == "success" for result in results) + assert [ + { + "job_id": row["job_id"], + "status": row["status"], + "billing_status": row["billing_status"], + "page_count": row["page_count"], + "credits_charged": row["credits_charged"], + } + for row in job_rows + ] == [ + { + "job_id": job_id, + "status": "done", + "billing_status": "charged", + "page_count": 1, + "credits_charged": expected_credits_charged, + } + for job_id in sorted(job_ids) + ] + assert all( + metadata_by_job_id[job_id] is not None + and metadata_by_job_id[job_id]["billing_status"] == "charged" + and metadata_by_job_id[job_id]["billing_amount_micro_dollars"] + == expected_credits_charged + for job_id in job_ids + ) + assert balance_row == { + "credits_balance": expected_initial_balance + - (expected_credits_charged * len(job_ids)) + } + assert transaction_counts == {"initial_grant": 1, "usage": len(job_ids)} + assert payment_count_row == {"count": 1} + + def test_should_skip_parse_task_when_the_job_is_already_terminal( worker_contract_environment: None, monkeypatch: MonkeyPatch, diff --git a/packages/shared-python/shared/services/billing/credits_service.py b/packages/shared-python/shared/services/billing/credits_service.py index 566440157..e4c8e03c8 100644 --- a/packages/shared-python/shared/services/billing/credits_service.py +++ b/packages/shared-python/shared/services/billing/credits_service.py @@ -11,7 +11,7 @@ from datetime import timedelta from typing import Any, Dict, Optional -from sqlalchemy.exc import IntegrityError +from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession from shared.core.billing import MicroDollar @@ -93,44 +93,39 @@ async def ensure_user_initialized( initial_dollars = getattr(settings, "FREE_PLAN_INITIAL_CREDITS", 5) initial_amount = MicroDollar.from_dollars(initial_dollars).amount - try: - async with session.begin_nested(): - # Create balance record - balance_entry = UserBalance( - user_id=user_id, credits_balance=initial_amount - ) - session.add(balance_entry) - - # Create initial transaction record - transaction = CreditsTransaction( - user_id=user_id, - credits_amount=initial_amount, - description="New user registration bonus", - transaction_type="initial_grant", - ) - session.add(transaction) - - # Create payment record for tracking - payment = PaymentRecord( - user_id=user_id, - payment_type="system_grant", - amount_cents=0, - currency="USD", - status="succeeded", - credits_amount=initial_amount, - extra_metadata={"reason": "initial_grant"}, - processed_at=utc_now_naive(), - ) - session.add(payment) - await session.flush() - except IntegrityError: - existing_balance = await self.repository.get_user_balance(session, user_id) - if existing_balance: - logger.info( - f"User already initialized by concurrent session: user_id={user_id}" - ) - return - raise + insert_balance_stmt = ( + pg_insert(UserBalance) + .values(user_id=user_id, credits_balance=initial_amount) + .on_conflict_do_nothing(index_elements=[UserBalance.user_id]) + .returning(UserBalance.user_id) + ) + insert_result = await session.execute(insert_balance_stmt) + inserted_user_id: str | None = insert_result.scalar_one_or_none() + if inserted_user_id is None: + return + + # Create initial transaction record only for the request that won creation. + transaction: CreditsTransaction = CreditsTransaction( + user_id=user_id, + credits_amount=initial_amount, + description="New user registration bonus", + transaction_type="initial_grant", + ) + session.add(transaction) + + # Create payment record for tracking only for the request that won creation. + payment: PaymentRecord = PaymentRecord( + user_id=user_id, + payment_type="system_grant", + amount_cents=0, + currency="USD", + status="succeeded", + credits_amount=initial_amount, + extra_metadata={"reason": "initial_grant"}, + processed_at=utc_now_naive(), + ) + session.add(payment) + await session.flush() logger.info(f"User initialized: user_id={user_id}, credits={initial_amount}") diff --git a/packages/shared-python/shared/services/billing/credits_sync_service.py b/packages/shared-python/shared/services/billing/credits_sync_service.py index 06b9bc4ed..ba6ddbe38 100644 --- a/packages/shared-python/shared/services/billing/credits_sync_service.py +++ b/packages/shared-python/shared/services/billing/credits_sync_service.py @@ -6,7 +6,7 @@ from typing import Any, Dict, Optional from sqlalchemy import func, select -from sqlalchemy.exc import IntegrityError +from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.orm import Session from shared.core.billing import MicroDollar @@ -41,41 +41,37 @@ def ensure_user_initialized( initial_dollars = getattr(settings, "FREE_PLAN_INITIAL_CREDITS", 5) initial_amount = MicroDollar.from_dollars(initial_dollars).amount - try: - with session.begin_nested(): - balance_entry = UserBalance( - user_id=user_id, credits_balance=initial_amount - ) - session.add(balance_entry) - - transaction = CreditsTransaction( - user_id=user_id, - credits_amount=initial_amount, - description="New user registration bonus", - transaction_type="initial_grant", - ) - session.add(transaction) - - payment = PaymentRecord( - user_id=user_id, - payment_type="system_grant", - amount_cents=0, - currency="USD", - status="succeeded", - credits_amount=initial_amount, - extra_metadata={"reason": "initial_grant"}, - processed_at=utc_now_naive(), - ) - session.add(payment) - session.flush() - except IntegrityError: - existing_balance = self.repository.get_user_balance(session, user_id) - if existing_balance: - logger.info( - f"User already initialized by concurrent session: user_id={user_id}" - ) - return - raise + insert_balance_stmt = ( + pg_insert(UserBalance) + .values(user_id=user_id, credits_balance=initial_amount) + .on_conflict_do_nothing(index_elements=[UserBalance.user_id]) + .returning(UserBalance.user_id) + ) + insert_result = session.execute(insert_balance_stmt) + inserted_user_id: str | None = insert_result.scalar_one_or_none() + if inserted_user_id is None: + return + + transaction: CreditsTransaction = CreditsTransaction( + user_id=user_id, + credits_amount=initial_amount, + description="New user registration bonus", + transaction_type="initial_grant", + ) + session.add(transaction) + + payment: PaymentRecord = PaymentRecord( + user_id=user_id, + payment_type="system_grant", + amount_cents=0, + currency="USD", + status="succeeded", + credits_amount=initial_amount, + extra_metadata={"reason": "initial_grant"}, + processed_at=utc_now_naive(), + ) + session.add(payment) + session.flush() logger.info(f"User initialized: user_id={user_id}, credits={initial_amount}") diff --git a/packages/shared-python/shared/services/retrieval/agent_navigate.py b/packages/shared-python/shared/services/retrieval/agent_navigate.py index ea127b69d..6bfaef2e8 100644 --- a/packages/shared-python/shared/services/retrieval/agent_navigate.py +++ b/packages/shared-python/shared/services/retrieval/agent_navigate.py @@ -3,7 +3,10 @@ import json import re -from typing import Any, Sequence +from typing import Any, Sequence, TYPE_CHECKING + +if TYPE_CHECKING: + from shared.services.retrieval.agentic.types import DocTreeNode from loguru import logger from sqlalchemy import func, select, or_ @@ -33,42 +36,60 @@ Do not include any explanation. """ -_VALID_HYDRATE_MODES = frozenset({ - 'outline', 'chunks', 'assets_only', 'image_only', 'table_only', -}) _SCOPE_NAV_PROMPT = """\ You are a document navigation assistant. Document: "{doc_name}" (id: {doc_id}) -Current scope: {scope_label} +{scope_header} -Below are candidate section paths at this scope level (up to 2 depth levels). -Indented items are sub-items of the item above. -Each item shows text/image/table counts. -Select section paths directly. A selected section path represents the chunks -under that section subtree; do not ask to drill deeper. +Below is the document's section tree. +Sections tagged [SELECT] are within the current scope and may be selected. +Other sections are shown as structural context only (not selectable). +Nodes marked [Leaf] have no further sub-sections. -=== Items === +=== Section Tree === {items_overview} -=== End Items === +=== End Section Tree === User query: {query} -Select the most relevant section paths (at most {max_select}). -If NO section path is relevant to the query, you MUST return an empty array []. Do not force-select irrelevant sections. -Prefer specific sub-items over broad parents when both are listed and the sub-item is sufficient. +Select sections to drill into for more detailed content. +- You may ONLY select sections marked with [SELECT]. Do NOT select any other sections. +- Select sections whose content is needed to answer the query. +- If the titles and summaries already visible are sufficient (e.g. the query asks for an outline or overview), return an EMPTY list []. + + Optional: set "mode" per selection to control what content is retrieved: + - "all" (default): retrieve all content types (text, images, tables) + - "image": retrieve only image assets from this section + - "table": retrieve only table assets from this section + +Return ONLY a JSON object: +{{"selections": [{{"path": "...", "confidence": , "mode": "all"}}, ...]}} +Do not include any explanation. +""" + + +_DISCOVERY_SELECT_PROMPT = """\ +You are a document navigation assistant. + +Document: "{doc_name}" + +After navigating the document's section tree, the following section paths +were additionally discovered via keyword and semantic search. +They may contain relevant evidence not found through hierarchical navigation. + +=== Discovery Candidates === +{items} +=== End Discovery Candidates === + +User query: {query} -For each selected path, assign a confidence score (0.0 to 1.0) where 1.0 means exactly answers the query and 0.5 means tangentially related. -Also choose a hydrate_mode: -- "chunks" (default) return all text/image/table chunks -- "outline" return only section title + summary, no chunk content -- "assets_only" return only image and table chunks -- "image_only" return only image chunks -- "table_only" return only table chunks +Select section paths whose content is needed to answer the query. +If none are relevant, return an EMPTY list []. -Return ONLY a JSON array: -[{{"path": "section/path", "confidence": , "hydrate_mode": "chunks"}}, ...] +Return ONLY a JSON object: +{{"selections": [{{"path": "...", "confidence": , "mode": "all"}}, ...]}} Do not include any explanation. """ @@ -113,39 +134,6 @@ def _normalize_confidence(value: Any) -> float | None: return max(0.0, min(parsed, 1.0)) -def _default_confidence_for_rank(rank: int) -> float: - return round(max(0.25, 0.85 - rank * 0.15), 4) - - -def _parse_chunk_path_selections(text: str) -> list[dict[str, Any]]: - """Parse chunk path selections from LLM output. - - Accepts either a legacy JSON array of strings or a structured array of - objects with `path`, optional `confidence`, and optional `hydrate_mode`. - """ - payload = _extract_json_array_payload(text) - selections: list[dict[str, Any]] = [] - for item in payload: - if isinstance(item, str): - path = item.strip() - if path: - selections.append({'path': path, 'confidence': None, 'hydrate_mode': 'chunks'}) - continue - if not isinstance(item, dict): - continue - path = str(item.get('path') or item.get('chunk_path') or '').strip() - if not path: - continue - raw_mode = str(item.get('hydrate_mode') or '').strip().lower() - hydrate_mode = raw_mode if raw_mode in _VALID_HYDRATE_MODES else 'chunks' - selections.append({ - 'path': path, - 'confidence': _normalize_confidence(item.get('confidence')), - 'hydrate_mode': hydrate_mode, - }) - return selections - - async def _build_knowledge_map_overview( db: AsyncSession, *, @@ -233,13 +221,13 @@ def _format_items_for_llm( items: list[dict], max_chars: int = 20000, ) -> tuple[str, bool]: - """Unified formatting with overflow guard for scope navigation. + """Format items with ▸ └ [Leaf] hierarchy for scope navigation. - Always shows ALL items (L1 + L2). Overflow controls whether - summaries are included — not which levels are shown. - - Normal: path + title + text=N image=I table=T + summary - Overflow: path + title + text=N image=I table=T (no summary) + Supports arbitrary depth levels via absolute ``level`` field. + Items with ``show_summary=False`` render title only (structural context). + ``[LN]`` tags indicate the absolute document depth of each section. + ``[Leaf]`` tags indicate bottom-level sections with no further children. + Summaries are included when within budget, dropped on overflow. Returns (text, overflowed). """ @@ -250,34 +238,53 @@ def _format_items_for_llm( SUMMARY_HEAD_TOKENS = 80 - def _render_line(item: dict, include_summary: bool) -> str: + def _render_item(item: dict, include_summary: bool) -> str: level = item.get('level', 1) - indent = ' ' if level == 2 else '' - line = f'{indent}- path="{item["path"]}" title="{item["title"]}"' - chunk_count = item.get('chunk_count', 0) - if chunk_count > 0: - line += f' text={chunk_count}' - image_count = item.get('image_count', 0) - if image_count > 0: - line += f' image={image_count}' - table_count = item.get('table_count', 0) - if table_count > 0: - line += f' table={table_count}' - if include_summary: - summary = item.get('summary') or item.get('title', '') - if summary: - clipped = truncate_content_preview(summary, head=SUMMARY_HEAD_TOKENS, tail=0) - line += f'\n{indent} summary: {clipped}' - return line - - # Try full render (with summaries) - full_lines = [_render_line(item, include_summary=True) for item in items] + show = item.get('show_summary', True) + is_leaf = item.get('is_leaf', False) + leaf_tag = ' [Leaf]' if is_leaf else '' + title = item.get('title', '') + path = item.get('path', '') + summary = item.get('summary') or '' + + # Build chunk count tags (only for current-scope items) + counts_str = '' + if show: + count_parts: list[str] = [] + chunk_count = item.get('chunk_count', 0) + if chunk_count > 0: + count_parts.append(f'text={chunk_count}') + image_count = item.get('image_count', 0) + if image_count > 0: + count_parts.append(f'image={image_count}') + table_count = item.get('table_count', 0) + if table_count > 0: + count_parts.append(f'table={table_count}') + counts_str = f' [{" ".join(count_parts)}]' if count_parts else '' + + indent = " " * (level - 1) + prefix = '▸' if level == 1 else '└' + level_tag = f'[L{level}]' + select_tag = '[SELECT] ' if show else '' + + lines: list[str] = [] + lines.append(f'{indent}{prefix} {select_tag}{level_tag} path="{path}" {title}{counts_str}{leaf_tag}') + + if include_summary and show and summary: + sub_indent = " " * level + clipped = truncate_content_preview(summary, head=SUMMARY_HEAD_TOKENS, tail=0) + lines.append(f'{sub_indent}{clipped}') + + return '\n'.join(lines) + + # Try full render (with summaries for show_summary=True items) + full_lines = [_render_item(item, include_summary=True) for item in items] full_text = '\n'.join(full_lines) if len(full_text) <= max_chars: return full_text, False # Overflow: render without summaries - slim_lines = [_render_line(item, include_summary=False) for item in items] + slim_lines = [_render_item(item, include_summary=False) for item in items] slim_text = '\n'.join(slim_lines) return slim_text[:max_chars], True @@ -433,18 +440,23 @@ async def _load_child_sections( scope_path: str | None = None, exclude_paths: set[str] | None = None, ) -> list[dict]: - """Load the next 2 available section depth bands under *scope_path*. + """Load the Continuous Context Tree for *scope_path*. + + Returns a flat list sorted by document order, each item: + {path, title, summary, chunk_count, image_count, table_count, + level, show_summary, is_leaf} - Returns a flat list sorted by sort_order, each item: - {path, title, summary, chunk_count, image_count, table_count, level} + The tree contains three categories of nodes: + 1. Ancestors of scope_path + their siblings → show_summary=False (title only) + 2. Children of scope_path (2 depth bands) → show_summary=True (with summary) + 3. Everything else → pruned (not returned) - - level=1: nearest available descendant depth under scope - - level=2: second nearest available descendant depth under scope - - chunk_count: text chunks under this section (excluding image/table) - - image_count: image chunks under this section - - table_count: table chunks under this section + When scope_path is None (root), all items are category 2. + + - level: absolute depth in the document (1-based) + - show_summary: controls whether _format_items_for_llm renders summary - exclude_paths: paths already seen in prior revision rounds; - any path matching (exact or subtree) is skipped + any path matching (exact or subtree) is skipped from category 2 """ # ── Fetch all sections for this document revision ──────────────────── stmt = ( @@ -468,7 +480,7 @@ async def _load_child_sections( scope_depth = len(scope_parts) # Build full section metadata index - all_sections: dict[str, dict] = {} # path → {title, summary, sort_order, section_id} + all_sections: dict[str, dict] = {} # path → {title, summary, sort_order, section_id, parts, depth} for section_id, title, path, summary, sort_order in section_rows: if not path: continue @@ -483,63 +495,138 @@ async def _load_child_sections( 'depth': len(parts), } - # ── Identify the next two real depth bands ─────────────────────────── - # - # The stored hierarchy is authoritative. Some ingested documents may only - # expose deeper section rows at root; in that case those rows become this - # round's relative L1/L2 instead of synthesizing missing ancestors. - visible_sections: list[tuple[str, dict, int]] = [] - visible_depths: set[int] = set() + # ── Build the set of ancestor prefixes for pruning ──────────────────── + # e.g. scope = "A / B / K" → ancestor_prefixes = {"A", "A / B", "A / B / K"} + ancestor_prefixes: set[str] = set() + for i in range(1, scope_depth + 1): + ancestor_prefixes.add(' / '.join(scope_parts[:i])) + + # ── Classify each section ──────────────────────────────────────────── _excl = exclude_paths or set() + items_by_path: dict[str, dict] = {} + scope_child_depths: set[int] = set() + for path, meta in all_sections.items(): parts = meta['parts'] - if scope_parts and ( - parts[:scope_depth] != scope_parts or len(parts) <= scope_depth - ): - continue - relative_depth = len(parts) - scope_depth - if relative_depth < 1: - continue - # Skip paths already seen in prior revision rounds - if _excl and any( - path == ep or path.startswith(ep + ' / ') or ep.startswith(path + ' / ') - for ep in _excl - ): + depth = meta['depth'] + + if scope_depth == 0: + # Root scope: everything is a potential child + if depth < 1: + continue + # Skip excluded paths + if _excl and any( + path == ep or path.startswith(ep + ' / ') + for ep in _excl + ): + continue + scope_child_depths.add(depth) + items_by_path[path] = { + 'path': path, + 'title': meta['title'], + 'summary': meta['summary'], + 'level': depth, + 'sort_order': meta['sort_order'], + 'chunk_count': 0, + 'image_count': 0, + 'table_count': 0, + 'section_id': meta['section_id'], + 'show_summary': True, # will be refined after depth band selection + } continue - visible_sections.append((path, meta, relative_depth)) - visible_depths.add(relative_depth) - - selected_depths = sorted(visible_depths)[:2] - depth_to_level = { - depth: idx + 1 - for idx, depth in enumerate(selected_depths) - } - - items_by_path: dict[str, dict] = {} - for path, meta, relative_depth in visible_sections: - level = depth_to_level.get(relative_depth) - if level is None: + # --- Non-root scope --- + + # Category 1: Ancestors and their siblings (structural context) + # A node is an ancestor/sibling if its depth <= scope_depth AND + # its parent prefix matches the scope's ancestry chain. + if depth <= scope_depth: + # Check: is this node in the ancestry chain or a sibling of one? + if depth == 1: + # All L1 nodes are either the ancestor or its siblings + items_by_path[path] = { + 'path': path, + 'title': meta['title'], + 'summary': meta['summary'], + 'level': depth, + 'sort_order': meta['sort_order'], + 'chunk_count': 0, + 'image_count': 0, + 'table_count': 0, + 'section_id': meta['section_id'], + 'show_summary': False, + } + elif depth <= scope_depth: + # For deeper ancestors/siblings: their parent must be in the + # ancestor chain. e.g. "A / C" is a sibling of "A / B" only + # if "A" is an ancestor of scope. + parent_prefix = ' / '.join(parts[:-1]) + if parent_prefix in ancestor_prefixes: + items_by_path[path] = { + 'path': path, + 'title': meta['title'], + 'summary': meta['summary'], + 'level': depth, + 'sort_order': meta['sort_order'], + 'chunk_count': 0, + 'image_count': 0, + 'table_count': 0, + 'section_id': meta['section_id'], + 'show_summary': False, + } continue - if path not in items_by_path: + + # Category 2: Descendants of scope_path (children to explore) + if parts[:scope_depth] == scope_parts and depth > scope_depth: + # Skip excluded paths + if _excl and any( + path == ep or path.startswith(ep + ' / ') + for ep in _excl + ): + continue + scope_child_depths.add(depth) items_by_path[path] = { 'path': path, 'title': meta['title'], 'summary': meta['summary'], - 'level': level, + 'level': depth, 'sort_order': meta['sort_order'], 'chunk_count': 0, 'image_count': 0, 'table_count': 0, 'section_id': meta['section_id'], + 'show_summary': True, } + continue + + # Category 3: Everything else → pruned (not added) + + if not items_by_path: + return [] + + # ── Limit children to 2 depth bands (relative to scope) ───────────── + if scope_child_depths: + if scope_depth == 0: + allowed_depths = sorted(scope_child_depths)[:2] + else: + allowed_depths = sorted(scope_child_depths)[:2] + allowed_set = set(allowed_depths) + to_remove = [] + for path, item in items_by_path.items(): + if item['show_summary'] and item['level'] not in allowed_set: + to_remove.append(path) + for path in to_remove: + del items_by_path[path] if not items_by_path: return [] # ── Count chunks per section (text / image / table) ────────────────── - section_ids = [meta['section_id'] for meta in all_sections.values()] - if section_ids: + # Only count for show_summary=True items (current scope children) + scope_item_sids = {item['section_id'] for item in items_by_path.values() if item['show_summary']} + # Also need all section_ids for upward aggregation + all_section_ids = [meta['section_id'] for meta in all_sections.values()] + if all_section_ids and scope_item_sids: from sqlalchemy import case, literal_column chunk_stmt = ( select( @@ -562,7 +649,7 @@ async def _load_child_sections( ) .where(DocumentChunk.document_id == document_id) .where(DocumentChunk.job_result_id == job_result_id) - .where(DocumentChunk.section_id.in_(section_ids)) + .where(DocumentChunk.section_id.in_(all_section_ids)) .group_by(DocumentChunk.section_id) ) chunk_rows = (await db.execute(chunk_stmt)).all() @@ -575,45 +662,242 @@ async def _load_child_sections( # Build section_id → path mapping for aggregation sid_to_path = {meta['section_id']: path for path, meta in all_sections.items()} - # Aggregate chunk counts upward: each item gets counts from itself + descendants + # Aggregate chunk counts upward: each show_summary item gets counts from itself + descendants for sid, (text_c, img_c, tbl_c) in section_id_counts.items(): chunk_path = sid_to_path.get(sid, '') if not chunk_path: continue - # Add to every ancestor item that is in our items_by_path for item_path, item in items_by_path.items(): + if not item['show_summary']: + continue if chunk_path == item_path or chunk_path.startswith(item_path + ' / '): item['chunk_count'] += text_c item['image_count'] += img_c item['table_count'] += tbl_c - # ── Sort: interleave L2 under their L1 parent ──────────────────────── - # - # Previous sort `(level, sort_order, path)` grouped all L1 first, then - # all L2. The LLM prompt uses indentation to show L2 as sub-items, so - # they should appear directly after their L1 parent for readability. - # - # Sort key: (parent_sort_order, is_child, own_sort_order) - # L1 items: (own_sort_order, 0, 0) → primary position - # L2 items: (parent_sort_order, 1, own) → right after their parent - - def _interleave_key(item: dict) -> tuple: - if item['level'] == 2: - parts = split_section_path(item['path']) - if len(parts) >= 2: - parent_path = ' / '.join(parts[:-1]) - parent_item = items_by_path.get(parent_path) - if parent_item is not None: - return (parent_item['sort_order'], 1, item['sort_order']) - # Orphan L2 (no matching L1 parent in this view): sort by own order - return (item['sort_order'], 1, item['sort_order']) - return (item['sort_order'], 0, 0) - - sorted_items = sorted(items_by_path.values(), key=_interleave_key) + # ── Sort by native document order ───────────────────────────────────── + sorted_items = sorted(items_by_path.values(), key=lambda x: x['sort_order']) # Clean up internal fields for item in sorted_items: item.pop('sort_order', None) item.pop('section_id', None) + + # ── Detect leaf status ──────────────────────────────────────────────── + # A section is a leaf if no other section in the database for this + # document has a path that descends from it. + all_section_paths = set(all_sections.keys()) + for item in sorted_items: + item_path = item['path'] + has_descendants = any( + p != item_path and p.startswith(item_path + ' / ') + for p in all_section_paths + ) + item['is_leaf'] = not has_descendants + return sorted_items +# --------------------------------------------------------------------------- +# LLM response parser (for scope_navigate) +# --------------------------------------------------------------------------- + +def _parse_scope_nav_response(text: str) -> list[dict[str, Any]]: + """Parse selections JSON from scope navigation LLM response. + + Returns list of {"path": str, "confidence": float, "mode": str}. + """ + text = text.strip() + # Try direct parse + try: + data = json.loads(text) + except (json.JSONDecodeError, ValueError): + # Extract JSON object from markdown wrapper + match = re.search(r'\{.*\}', text, re.DOTALL) + if not match: + return [] + try: + data = json.loads(match.group()) + except (json.JSONDecodeError, ValueError): + return [] + + if not isinstance(data, dict): + return [] + + selections: list[dict[str, Any]] = [] + _VALID_MODES = {'all', 'image', 'table'} + for item in (data.get('selections') or []): + if not isinstance(item, dict): + continue + path = str(item.get('path') or '').strip() + if not path: + continue + confidence = _normalize_confidence(item.get('confidence')) + if confidence is None: + confidence = 0.7 + mode = str(item.get('mode') or 'all').strip().lower() + if mode not in _VALID_MODES: + mode = 'all' + selections.append({'path': path, 'confidence': confidence, 'mode': mode}) + + return selections + + +# --------------------------------------------------------------------------- +# Unified document tree rendering (DocTreeNode → single coherent hierarchy) +# --------------------------------------------------------------------------- + +def _render_leaf_chunks( + parts: list[str], + chunks: list[dict[str, Any]], + indent: str, + asset_lookup: dict[str, str] | None = None, +) -> None: + """Render hydrated leaf chunks inline with table/image inlining and dedup. + + Uses ``connect_to`` metadata to resolve asset references — the same + pattern as ``assemble_retrieval_results``: + - **Tables**: inline HTML content at the ``ref`` placeholder + - **Images**: inline the ``file_path`` (S3-compatible URL) at the + placeholder for multimodal LLMs + + Connected target chunks (images/tables) are expected to already be + present in ``chunks`` via ``hydrate_connected_target_rows``. + """ + chunk_by_id: dict[str, dict] = { + c.get('chunk_id', ''): c for c in chunks if c.get('chunk_id') + } + rendered_ids: set[str] = set() + for chunk in chunks: + cid = chunk.get('chunk_id', '') + if cid and cid in rendered_ids: + continue + if cid: + rendered_ids.add(cid) + + chunk_type = (chunk.get('chunk_type') or chunk.get('type') or 'text').strip().lower() + + # Skip standalone image/table chunks — they'll be inlined + # via connect_to from their parent text chunk + if chunk_type in ('image', 'table'): + continue + + content = str(chunk.get('content', '')).strip() + + # Resolve connected assets via connect_to metadata + for conn in (chunk.get('chunk_metadata') or {}).get('connect_to') or []: + target = chunk_by_id.get(conn.get('target', '')) + if not target: + continue + target_cid = target.get('chunk_id', '') + target_type = (target.get('chunk_type') or target.get('type') or '').strip().lower() + ref_str = conn.get('ref', '') + if not ref_str or ref_str not in content: + continue + + if target_cid: + rendered_ids.add(target_cid) + + if target_type == 'table': + table_html = str(target.get('content', '')).strip() + content = content.replace(ref_str, f'\n[表格内容]\n{table_html}\n') + elif target_type == 'image': + file_path = target.get('file_path') or '' + img_desc = str(target.get('content', '')).strip() + # Strip self-reference from image description + if ref_str in img_desc: + img_desc = img_desc.replace(ref_str, '').strip() + # Use pre-generated asset URL if available, fall back to file_path + asset_url = (asset_lookup or {}).get(target_cid, '') if target_cid else '' + display_ref = asset_url or file_path + if display_ref: + content = content.replace(ref_str, f'\n[图片: {display_ref}]\n{img_desc}\n') + elif img_desc: + content = content.replace(ref_str, f'\n[图片描述]\n{img_desc}\n') + + for line in content.split('\n'): + if line.strip(): + parts.append(f'{indent}┈ {line}') + + +def render_unified_doc_tree( + node: DocTreeNode, + doc_name: str, + depth: int = 0, + asset_lookup: dict[str, str] | None = None, +) -> str: + """Render a DocTreeNode as a single coherent hierarchy. + + Summaries are navigation-only aids and NEVER appear in evidence. + The rendered output contains: + 1. Structural titles for ALL sections (positioning context) + 2. Hydrated chunk content (┈ lines) ONLY for selected leaf paths + + Asset references (tables/images) are resolved via ``connect_to`` + metadata in hydrated chunks — no separate lookup needed. + """ + + parts: list[str] = [] + indent = ' ' * depth + + if depth == 0: + parts.append(f'【文档】{doc_name}\n') + + # Track which paths have been rendered via outline_items + rendered_paths: set[str] = set() + + # Collect children keys for path-hierarchy dedup: + child_prefixes = set(node.children.keys()) + + for item in node.outline_items: + path = item.get('path', '') + title = item.get('title', '') + is_leaf = item.get('is_leaf', False) + level = item.get('level', 1) + leaf_tag = ' [Leaf]' if is_leaf else '' + + # Skip items that belong to a drilled-into child's subtree + if any(path.startswith(cp + ' / ') for cp in child_prefixes): + continue + + rendered_paths.add(path) + + # Section header (title only — summaries are navigation aids, not evidence) + level_tag = f'[L{level}] ' if level else '' + if level <= 1: + parts.append(f'{indent}▸ {level_tag}{title}{leaf_tag}') + else: + parts.append(f'{indent}└ {level_tag}{title}{leaf_tag}') + + sub_indent = indent + ' ' + + # Case 1: This section was drilled into → show child tree inline + if path in node.children: + child = node.children[path] + child_text = render_unified_doc_tree(child, doc_name, depth + 1, asset_lookup=asset_lookup) + if child_text.strip(): + parts.append(child_text) + + # Case 2: This is a hydrated leaf → show chunk content inline + elif path in node.leaf_content: + _render_leaf_chunks(parts, node.leaf_content[path], sub_indent, asset_lookup=asset_lookup) + + # Case 3: Unselected → title already rendered above, nothing more needed + + # Render orphan paths: leaf_content and children not covered by outline_items + for path in node.leaf_content: + if path not in rendered_paths: + title = path.rsplit(' / ', 1)[-1] if ' / ' in path else path + parts.append(f'{indent}▸ [Leaf] {title}') + sub_indent = indent + ' ' + _render_leaf_chunks(parts, node.leaf_content[path], sub_indent, asset_lookup=asset_lookup) + + for path in node.children: + if path not in rendered_paths: + title = path.rsplit(' / ', 1)[-1] if ' / ' in path else path + parts.append(f'{indent}▸ {title} [DrillDown]') + child_text = render_unified_doc_tree(node.children[path], doc_name, depth + 1, asset_lookup=asset_lookup) + if child_text.strip(): + parts.append(child_text) + + return '\n'.join(parts) + diff --git a/packages/shared-python/shared/services/retrieval/agentic/__init__.py b/packages/shared-python/shared/services/retrieval/agentic/__init__.py index b34bfdb63..e4b5a0695 100644 --- a/packages/shared-python/shared/services/retrieval/agentic/__init__.py +++ b/packages/shared-python/shared/services/retrieval/agentic/__init__.py @@ -1,8 +1,14 @@ """Agentic retrieval orchestration for Knowhere. -Upgrades the retrieval pipeline from a fixed sequence to a state/action/observation -loop with explicit tool selection, budget controls, and trajectory recording. +Navigate-then-answer loop: + Phase 1: Document selection (discovery + KG LLM select) + Phase 2: Per-document iterative navigation (scope_navigate_step) + Phase 3: attempt_answer → DONE (return answer) or NOT_FOUND → revision -All tools are thin wrappers around existing retrieval components — no new retrieval -algorithms, ranking strategies, or prompts are introduced. +Navigation auto-terminates when the LLM returns empty selections. +After navigation, attempt_answer is called automatically — its result +(answer or NOT_FOUND+reason) drives the revision loop. + +All tools are thin wrappers around existing retrieval components — no new +retrieval algorithms, ranking strategies, or prompts are introduced. """ diff --git a/packages/shared-python/shared/services/retrieval/agentic/orchestrator.py b/packages/shared-python/shared/services/retrieval/agentic/orchestrator.py index 5893b416a..776244317 100644 --- a/packages/shared-python/shared/services/retrieval/agentic/orchestrator.py +++ b/packages/shared-python/shared/services/retrieval/agentic/orchestrator.py @@ -1,12 +1,18 @@ -"""Retrieval Agent orchestrator — the core agent loop. - -Runs the state/action/observation cycle using the LLM-driven LLMPolicy -and agentic tools. The fixed terminal step (hydrate + rank) always -executes, even if all tools fail — in that case it uses whatever -discovery rows were collected. - -This module never raises exceptions to the caller. Errors are captured -in the trace and the best available result is returned. +"""Retrieval Agent orchestrator — navigate-then-answer loop. + +Flow: + Phase 1: Document selection (bottom_discovery + kg_document_select) + Phase 2: Per-document navigation (iterative BFS scope_navigate_step) + Phase 3: Render evidence → attempt_answer + → DONE (has answer) → return answer + evidence + → NOT_FOUND + reason → revision_hint → re-select docs + re-navigate + (exclude seen paths) → re-attempt + → max_revisions → return best available + +The orchestrator drives navigation via an iterative BFS queue per document, +calling scope_navigate_step at each level. Navigation auto-terminates when +the LLM returns empty selections. After navigation completes, attempt_answer +is called automatically — no separate verdict step needed. """ from __future__ import annotations @@ -14,48 +20,128 @@ from typing import Any from loguru import logger +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from shared.models.database.document import Document + from shared.services.retrieval.agentic.trace import TraceRecorder from shared.services.retrieval.agentic.types import ( - ActionType, AgentRunConfig, AgentState, + AgenticResult, + CandidateDoc, + DocTreeNode, ToolResult, ) from shared.services.retrieval.app_service import ( - _hydrate_paths_to_rows, - _load_chunk_importance_scores, - _normalize_row_scores, - _rank_candidates_by_path, + generate_retrieval_asset_url, + _is_client_result_artifact_ref, ) from shared.services.retrieval.llm_adapter import LLMFn + + + + +def _collect_media_chunks(node: DocTreeNode) -> list[dict[str, Any]]: + """Recursively collect image/table chunks from a doc tree's leaf_content.""" + media: list[dict[str, Any]] = [] + for chunks in node.leaf_content.values(): + for chunk in chunks: + ct = (chunk.get('chunk_type') or chunk.get('type') or '').strip().lower() + if ct in ('image', 'table'): + media.append(chunk) + for child in node.children.values(): + media.extend(_collect_media_chunks(child)) + return media + + +async def _build_asset_url_map( + media_chunks: list[dict[str, Any]], +) -> dict[str, str]: + """Generate presigned asset URLs for media chunks. + + Uses the same ``generate_retrieval_asset_url`` as ``_to_public_response`` + in ``app_service.py`` — no separate logic. + """ + url_map: dict[str, str] = {} + for chunk in media_chunks: + chunk_id = str(chunk.get('chunk_id') or '').strip() + file_path = chunk.get('file_path') or '' + job_id = chunk.get('job_id') or '' + if not chunk_id or not file_path or not job_id: + continue + if not _is_client_result_artifact_ref(file_path): + continue + try: + url = await generate_retrieval_asset_url( + job_id=str(job_id), + artifact_ref=str(file_path), + ) + if url: + url_map[chunk_id] = url + except Exception as e: + logger.warning(f'Failed to generate asset URL for {chunk_id} (ignored): {e}') + return url_map + + def _build_config_from_env() -> AgentRunConfig: """Read agent config from environment, with sensible defaults.""" return AgentRunConfig( - max_steps=int(os.environ.get('RETRIEVAL_AGENTIC_MAX_STEPS', '10')), - max_docs=int(os.environ.get('RETRIEVAL_AGENTIC_MAX_DOCS', '0')), - max_path_expansions=int(os.environ.get('RETRIEVAL_AGENTIC_MAX_PATH_EXPANSIONS', '2')), - max_doc_retries=int(os.environ.get('RETRIEVAL_AGENTIC_MAX_DOC_RETRIES', '2')), + max_revisions=int(os.environ.get('RETRIEVAL_AGENTIC_MAX_REVISIONS', '2')), + max_nav_depth=int(os.environ.get('RETRIEVAL_AGENTIC_MAX_NAV_DEPTH', '3')), latency_budget_ms=int(os.environ.get('RETRIEVAL_AGENTIC_LATENCY_BUDGET_MS', '12000')), - min_evidence_paths=int(os.environ.get('RETRIEVAL_AGENTIC_MIN_EVIDENCE_PATHS', '1')), ) +async def _render_evidence( + db: AsyncSession, + doc_trees: dict[str, DocTreeNode], + doc_id_to_name: dict[str, str], +) -> str: + """Render unified evidence text from doc trees. + + Discovery paths are now handled by ``discovery_select_step`` in Phase 2 + and merged into doc_trees — no separate fallback needed. + """ + from shared.services.retrieval.agent_navigate import render_unified_doc_tree + + # Build asset URL map for all media chunks (images/tables) + # — same pattern as _to_public_response in app_service.py + all_media_chunks: list[dict[str, Any]] = [] + for doc_tree in doc_trees.values(): + all_media_chunks.extend(_collect_media_chunks(doc_tree)) + asset_url_map = await _build_asset_url_map(all_media_chunks) + + # Render unified evidence from doc trees + evidence_parts: list[str] = [] + for doc_id, doc_tree in doc_trees.items(): + if doc_tree.has_content(): + doc_name = doc_id_to_name.get(doc_id, doc_id) + rendered = render_unified_doc_tree(doc_tree, doc_name, asset_lookup=asset_url_map) + if rendered.strip(): + evidence_parts.append(rendered) + + return '\n\n'.join(evidence_parts) if evidence_parts else '(no evidence collected)' + + class RetrievalAgent: - """Agentic retrieval orchestrator using LLMPolicy. + """Agentic retrieval orchestrator — navigate-then-answer loop. Usage:: agent = RetrievalAgent() - ranked_rows, router = await agent.run( + result = await agent.run( db, user_id=..., namespace=..., query=..., llm_fn=..., ... ) + # result.evidence_text — hierarchical context + # result.answer_text — LLM answer (empty if NOT_FOUND after max revisions) + # result.referenced_chunks — chunk IDs for hit stats / frontend - The agent requires a valid ``llm_fn`` to run LLMPolicy. If ``llm_fn`` - is None, the run terminates immediately after bottom_discovery only. + The agent requires a valid ``llm_fn`` for LLM-driven navigation. + If ``llm_fn`` is None, the run returns discovery-only results. """ async def run( @@ -75,13 +161,16 @@ async def run( channels: list[str] | None = None, channel_weights: dict[str, float] | None = None, config: AgentRunConfig | None = None, - ) -> tuple[list[dict[str, Any]], str]: + ) -> AgenticResult: """Run the agentic retrieval pipeline. - Returns (ranked_rows, router_used). Never raises — errors are - captured in trace and the best available result is returned. + Returns an ``AgenticResult`` containing the rendered evidence + text, LLM answer, and referenced chunk IDs. Never raises — + errors are captured in trace and the best available result + is returned. """ - from shared.services.retrieval.agentic.policy import LLMPolicy + from shared.services.retrieval.agentic import tools + from shared.services.retrieval.agentic.policy import attempt_answer config = config or _build_config_from_env() exclude_document_ids = exclude_document_ids or [] @@ -104,23 +193,18 @@ async def run( logger.info( f'agentic retrieval START: query="{query[:60]}..." ' - f'top_k={top_k} max_steps={config.max_steps} ' - f'budget={config.latency_budget_ms}ms' + f'top_k={top_k} budget={config.latency_budget_ms}ms' ) if llm_fn is None: logger.warning('agentic: no llm_fn provided — running discovery-only mode') - # Build LLMPolicy (needs llm_fn; gracefully handles None) - policy = LLMPolicy(llm_fn, query=query) if llm_fn else None - - # Shared kwargs for tool calls - tool_kwargs: dict[str, Any] = { + # Shared kwargs for bottom_discovery + discovery_kwargs: dict[str, Any] = { 'user_id': user_id, 'namespace': namespace, 'query': query, 'top_k': top_k, - 'llm_fn': llm_fn, 'exclude_document_ids': exclude_document_ids, 'exclude_sections': exclude_sections, 'data_type': data_type, @@ -130,345 +214,417 @@ async def run( 'channel_weights': channel_weights, } - # ── Mandatory pre-step: bottom discovery ───────────────────────────── - # bottom_discovery is always the first action; running it via the LLM - # policy wastes ~1-2s on a trivial LLM call. We execute it directly - # and let the LLM loop start from step 2 (kg_document_select etc.). - logger.info(' agentic: running mandatory bottom_discovery pre-step') - discovery_result = await self._execute_tool( - db, ActionType.BOTTOM_DISCOVERY, state, config, **tool_kwargs, - ) - state.apply(ActionType.BOTTOM_DISCOVERY, discovery_result) + # ══════════════════════════════════════════════════════════════════ + # Phase 1: Discovery + Document Selection + # ══════════════════════════════════════════════════════════════════ + logger.info(' agentic: Phase 1 — discovery + document selection') + + # 1a. Bottom discovery (always runs) + discovery_result = await tools.bottom_discovery(db, **discovery_kwargs) + state.step_count += 1 + discovery_rows = discovery_result.payload.get('fused_rows', []) if discovery_result.status != 'error' else [] + state.discovery_top_doc_ids = discovery_result.payload.get('top_doc_ids', []) if discovery_result.status != 'error' else [] + if trace_enabled: trace.record_step( - ActionType.BOTTOM_DISCOVERY, discovery_result, - decision_reason='mandatory_pre_step', + 'bottom_discovery', discovery_result, + decision_reason='phase_1_mandatory', ) - state.step_count += 1 + logger.info( - f' agentic step {state.step_count} (pre-step): action=bottom_discovery ' + f' agentic step {state.step_count}: bottom_discovery ' f'status={discovery_result.status} latency={discovery_result.latency_ms}ms' ) - # ── Agent loop (LLM decisions start from here) ──────────────────────── - stop_reason = 'max_steps' - while state.step_count < config.max_steps: - if state.elapsed_ms >= config.latency_budget_ms: - logger.info( - f' agentic: latency budget hit ({state.elapsed_ms}ms >= {config.latency_budget_ms}ms), ' - f'stopping at step {state.step_count}' - ) - trace.record_budget_stop('latency') - stop_reason = 'latency_budget' - break + # 1b. KG document selection (requires LLM) + if llm_fn is not None: + kg_result = await tools.kg_document_select( + db, + user_id=user_id, + namespace=namespace, + query=query, + llm_fn=llm_fn, + exclude_document_ids=list(state.ever_explored_doc_ids | set(exclude_document_ids)), + ) + state.step_count += 1 - if policy is None: - # No LLM: discovery already ran — stop - stop_reason = 'no_llm_fn' - break - else: - action_type, decision_reason = await policy.decide(state, config) - - if action_type is None or action_type == ActionType.DONE: - stop_reason = 'llm_done' if action_type == ActionType.DONE else 'llm_stop' - logger.info( - f' agentic: policy returned {action_type} at step {state.step_count} ' - f'(reason="{decision_reason}"), stopping' + if trace_enabled: + trace.record_step( + 'kg_document_select', kg_result, + decision_reason='phase_1_doc_selection', ) - # Record DONE as a trace step - if trace_enabled and action_type == ActionType.DONE: - trace.record_step( - ActionType.DONE, - ToolResult(status='done', payload={'reason': decision_reason}), - decision_reason=decision_reason, + + if kg_result.status == 'selected_docs': + for doc_data in kg_result.payload.get('candidate_docs', []): + state.selected_docs.append(CandidateDoc( + document_id=doc_data.get('document_id', ''), + source_file_name=doc_data.get('source_file_name', ''), + confidence=doc_data.get('confidence', 0.0), + reason=doc_data.get('reason', ''), + source=doc_data.get('source', ''), + )) + state.doc_id_to_name.update(kg_result.payload.get('doc_id_to_name', {})) + state.doc_job_map.update(kg_result.payload.get('doc_job_map', {})) + + # If KG returned nothing, use discovery hints + if not state.selected_docs and state.discovery_top_doc_ids: + hint_ids = [d for d in state.discovery_top_doc_ids if d not in state.ever_explored_doc_ids] + if hint_ids: + doc_stmt = ( + select(Document.document_id, Document.source_file_name, Document.current_job_result_id) + .where(Document.document_id.in_(hint_ids)) ) - break + doc_result = await db.execute(doc_stmt) + for did, fname, jrid in doc_result.all(): + state.selected_docs.append(CandidateDoc( + document_id=did, + source_file_name=fname or did, + confidence=0.5, + reason='discovery_hint (KG returned 0)', + source='discovery_hint', + )) + state.doc_id_to_name[did] = fname or did + if jrid: + state.doc_job_map[did] = jrid - result = await self._execute_tool(db, action_type, state, config, **tool_kwargs) - state.apply(action_type, result) + logger.info( + f' agentic step {state.step_count}: kg_document_select ' + f'status={kg_result.status} docs={len(state.selected_docs)} ' + f'latency={kg_result.latency_ms}ms' + ) + # If no LLM or no docs selected, return discovery rows directly + if not state.selected_docs: + logger.info('agentic: no documents selected — returning discovery results') + discovery_refs = [ + { + 'chunk_id': r.get('chunk_id', ''), + 'document_id': r.get('document_id', ''), + 'chunk_type': r.get('chunk_type', ''), + 'section_path': r.get('section_path', ''), + 'file_path': r.get('file_path', ''), + } + for r in discovery_rows[:top_k] + if r.get('chunk_id') + ] if trace_enabled: - trace.record_step(action_type, result, decision_reason=decision_reason) + await trace.complete(discovery_rows, 'agentic_discovery_only') + return AgenticResult( + evidence_text='', + answer_text='', + referenced_chunks=discovery_refs, + router_used='agentic_discovery_only', + ) - state.step_count += 1 + # ══════════════════════════════════════════════════════════════════ + # Discovery → Navigation integration + # Group discovery_rows by document for post-BFS discovery selection + # ══════════════════════════════════════════════════════════════════ + discovery_by_doc: dict[str, list[dict[str, Any]]] = {} + for row in discovery_rows: + doc_id = row.get('document_id', '') + if doc_id: + discovery_by_doc.setdefault(doc_id, []).append(row) + + # Auto-register B-class docs (discovery-only, not selected by KG) + selected_doc_ids = {d.document_id for d in state.selected_docs} + for doc_id in discovery_by_doc: + if doc_id not in selected_doc_ids and doc_id not in state.ever_explored_doc_ids: + doc_stmt = ( + select(Document.document_id, Document.source_file_name, Document.current_job_result_id) + .where(Document.document_id == doc_id) + ) + doc_result = await db.execute(doc_stmt) + row_data = doc_result.first() + if row_data: + did, fname, jrid = row_data + state.selected_docs.append(CandidateDoc( + document_id=did, + source_file_name=fname or did, + confidence=0.4, + reason='discovery_auto (not in KG selection)', + source='discovery_auto', + )) + state.doc_id_to_name[did] = fname or did + if jrid: + state.doc_job_map[did] = jrid + + # ══════════════════════════════════════════════════════════════════ + # Phase 2 + 3 Loop: Navigate → Render → Attempt Answer → (Revise) + # ══════════════════════════════════════════════════════════════════ + answer_text = '' + revision_hint: str | None = None + stop_reason = 'max_revisions' + + for round_idx in range(config.max_revisions + 1): + if state.elapsed_ms >= config.latency_budget_ms: + stop_reason = 'latency_budget' + break + # ── Phase 2: Per-Document Navigation ──────────────────────── logger.info( - f' agentic step {state.step_count}: action={action_type.value} ' - f'status={result.status} latency={result.latency_ms}ms ' - f'docs={len(state.selected_docs)} paths={len(state.selected_paths)}' + f' agentic: Phase 2 (round {round_idx}) — ' + f'navigating {len(state.selected_docs)} documents' ) - # ── Terminal: hydrate + rank + attempt_answer loop ── - while True: - ranked_rows = await self._hydrate_and_rank( - db, state, user_id=user_id, namespace=namespace, top_k=top_k, - ) + for doc in state.selected_docs: + if state.elapsed_ms >= config.latency_budget_ms: + logger.info(' agentic: latency budget hit during Phase 2, stopping') + break - # Include kept rows from prior revision rounds - if state.kept_path_rows: - ranked_rows = state.kept_path_rows + ranked_rows + job_result_id = state.doc_job_map.get(doc.document_id, '') + if not job_result_id: + logger.info(f' agentic: skipping doc {doc.document_id} — no job_result_id') + state.ever_explored_doc_ids.add(doc.document_id) + continue + + doc_name = doc.source_file_name or state.doc_id_to_name.get(doc.document_id, '') + + # B-class docs (discovery_auto) skip BFS, go to discovery_select + is_b_class = doc.source == 'discovery_auto' + + if not is_b_class: + # Build exclude_paths for this doc from seen_section_keys + doc_exclude = { + key.split('::', 1)[1] + for key in state.seen_section_keys + if key.startswith(f'{doc.document_id}::') + } if state.seen_section_keys else None + + # BFS queue: (scope_path, parent_node, depth) + root = DocTreeNode(scope_path=None) + pending: list[tuple[str | None, DocTreeNode, int]] = [(None, root, 0)] + + while pending: + if state.elapsed_ms >= config.latency_budget_ms: + break + + scope, parent_node, depth = pending.pop(0) + if depth >= config.max_nav_depth: + continue + + if llm_fn is None: + break + + step_node, drill_paths = await tools.scope_navigate_step( + db, + document_id=doc.document_id, + job_result_id=job_result_id, + query=query, + llm_fn=llm_fn, + user_id=user_id, + namespace=namespace, + doc_name=doc_name, + scope_path=scope, + exclude_paths=doc_exclude, + revision_hint=revision_hint if depth == 0 else None, + ) + state.step_count += 1 + + # Merge step result into parent node + parent_node.outline_items = step_node.outline_items + parent_node.leaf_content = step_node.leaf_content + parent_node.confidence = step_node.confidence + + # Queue non-leaf selections for further drill-down + for sel in drill_paths: + child = DocTreeNode(scope_path=sel['path']) + parent_node.children[sel['path']] = child + pending.append((sel['path'], child, depth + 1)) + + if trace_enabled: + trace.record_step( + 'scope_navigate_step', ToolResult( + status='navigated' if step_node.has_content() else 'empty', + payload={ + 'document_id': doc.document_id, + 'scope': scope or 'root', + 'depth': depth, + 'outline_count': len(step_node.outline_items), + 'leaf_count': len(step_node.leaf_content), + 'pending_drills': len(drill_paths), + }, + ), + decision_reason=f'nav_r{round_idx}_d{depth}_{doc.source_file_name}', + ) + + logger.info( + f' agentic step {state.step_count}: scope_navigate_step ' + f'doc="{doc.source_file_name}" scope={scope or "root"} ' + f'depth={depth} outline={len(step_node.outline_items)} ' + f'leaves={len(step_node.leaf_content)} ' + f'drills={len(drill_paths)}' + ) + else: + # B-class: no BFS, create empty root + root = DocTreeNode(scope_path=None) + + # ── Post-BFS: Discovery selection step ───────────────────── + doc_hints = discovery_by_doc.get(doc.document_id, []) + if doc_hints and llm_fn is not None and state.elapsed_ms < config.latency_budget_ms: + discovery_node = await tools.discovery_select_step( + db, + document_id=doc.document_id, + query=query, + llm_fn=llm_fn, + user_id=user_id, + namespace=namespace, + doc_name=doc_name, + discovery_hints=doc_hints, + ) + state.step_count += 1 - # Check if we should attempt_answer (need LLM + results + revision budget) - if ( - policy is None - or not ranked_rows - or state.revision_count >= config.max_revisions - ): - break + if trace_enabled: + trace.record_step( + 'discovery_select_step', ToolResult( + status='selected' if discovery_node.has_content() else 'empty', + payload={ + 'document_id': doc.document_id, + 'hints_count': len(doc_hints), + 'hydrated_count': len(discovery_node.leaf_content), + }, + ), + decision_reason=f'discovery_r{round_idx}_{doc.source_file_name}', + ) + + # Merge discovery results into BFS tree + root.merge(discovery_node) + + # Merge or store doc tree + if doc.document_id in state.doc_trees: + state.doc_trees[doc.document_id].merge(root) + else: + state.doc_trees[doc.document_id] = root + state.ever_explored_doc_ids.add(doc.document_id) + + # ── Phase 3: Render evidence + attempt_answer ──────────────── + evidence_text = await _render_evidence( + db, + state.doc_trees, state.doc_id_to_name, + ) - # KG-exhausted guard: if all selected docs have been explored, - # further revision won't find new content — skip attempt_answer - all_selected_ids = {d.document_id for d in state.selected_docs} - unexplored = all_selected_ids - state.ever_explored_doc_ids - kg_exhausted = len(unexplored) == 0 and len(all_selected_ids) > 0 - if kg_exhausted: - logger.info( - f' agentic: KG exhausted — all {len(all_selected_ids)} docs explored, ' - f'skipping attempt_answer' - ) - stop_reason = 'kg_exhausted' + if llm_fn is None: + stop_reason = 'no_llm' break - # Three-state verdict from LLM - verdict, verdict_reason = await policy.attempt_answer( - state, config, ranked_rows, + # Auto-trigger attempt_answer + status, answer_text, reason = await attempt_answer( + llm_fn, + query=query, + evidence_text=evidence_text, + state=state, + config=config, ) - logger.info( - f' agentic attempt_answer: verdict={verdict} ' - f'revision={state.revision_count}/{config.max_revisions} ' - f'reason="{verdict_reason}"' - ) - - if verdict == 'DONE': - stop_reason = 'attempt_done' - break + state.step_count += 1 - if verdict in ('NOT_SUFFICIENT', 'NOT_FOUND'): - state.revision_count += 1 - # Save current results as kept rows for next round - state.kept_path_rows = ranked_rows - # Record current selected paths as seen - for p in state.selected_paths: - doc_id = p.get('document_id', '') - path = p.get('path', '') - if doc_id and path: - state.seen_section_keys.add(f'{doc_id}::{path}') - # Reset navigation state for re-exploration - state.selected_paths.clear() - state.selected_docs.clear() # Bug 1 fix: prevent doc accumulation - state.pending_doc_index = 0 - state.kg_done = False - state.discovery_done = False - - # Mandatory bottom_discovery pre-step for revision round - logger.info( - f' agentic: running mandatory bottom_discovery pre-step ' - f'(revision {state.revision_count})' - ) - rev_discovery = await self._execute_tool( - db, ActionType.BOTTOM_DISCOVERY, state, config, **tool_kwargs, - ) - state.apply(ActionType.BOTTOM_DISCOVERY, rev_discovery) - if trace_enabled: - trace.record_step( - ActionType.BOTTOM_DISCOVERY, rev_discovery, - decision_reason=f'mandatory_pre_step (revision {state.revision_count})', - ) - state.step_count += 1 - logger.info( - f' agentic step {state.step_count} (rev {state.revision_count} pre-step): ' - f'action=bottom_discovery status={rev_discovery.status}' + if trace_enabled: + trace.record_step( + 'attempt_answer', ToolResult( + status=status, + payload={ + 'answer_length': len(answer_text), + 'reason': reason, + }, + ), + decision_reason=f'phase_3_answer_r{round_idx}', ) - # Re-enter agent loop - while state.step_count < config.max_steps: - if state.elapsed_ms >= config.latency_budget_ms: - stop_reason = 'latency_budget' - break + logger.info( + f' agentic: attempt_answer status={status} ' + f'round={round_idx}/{config.max_revisions} ' + f'answer_len={len(answer_text)} reason="{reason}"' + ) - action_type, decision_reason = await policy.decide(state, config) - if action_type is None or action_type == ActionType.DONE: - stop_reason = 'llm_done' - break + if status == 'DONE': + stop_reason = 'answer_done' + break - result = await self._execute_tool( - db, action_type, state, config, **tool_kwargs, - ) - state.apply(action_type, result) - if trace_enabled: - trace.record_step(action_type, result, decision_reason=decision_reason) - state.step_count += 1 - logger.info( - f' agentic step {state.step_count} (rev {state.revision_count}): ' - f'action={action_type.value} status={result.status}' - ) + # ── NOT_FOUND: prepare revision ────────────────────────────── + if round_idx >= config.max_revisions: + stop_reason = 'max_revisions' + break - # Loop back to hydrate + attempt_answer - continue + state.revision_count += 1 + revision_hint = reason + logger.info(f' agentic: starting revision {state.revision_count}, hint="{reason}"') + + # Record all explored paths for masking (deepest-first removal) + for doc_id, doc_tree in state.doc_trees.items(): + state.seen_section_keys.update(doc_tree.collect_all_paths(doc_id)) + + # Clear doc selection for re-exploration (preserve doc_trees for merge) + state.selected_docs.clear() + + # Re-run KG select (allow re-exploring docs for different sections via path masking) + kg_result = await tools.kg_document_select( + db, + user_id=user_id, + namespace=namespace, + query=query, + llm_fn=llm_fn, + exclude_document_ids=list(set(exclude_document_ids)), + ) + state.step_count += 1 - # Unknown verdict — treat as DONE - break + if kg_result.status == 'selected_docs': + for doc_data in kg_result.payload.get('candidate_docs', []): + state.selected_docs.append(CandidateDoc( + document_id=doc_data.get('document_id', ''), + source_file_name=doc_data.get('source_file_name', ''), + confidence=doc_data.get('confidence', 0.0), + reason=doc_data.get('reason', ''), + source=doc_data.get('source', ''), + )) + state.doc_id_to_name.update(kg_result.payload.get('doc_id_to_name', {})) + state.doc_job_map.update(kg_result.payload.get('doc_job_map', {})) + + if not state.selected_docs: + logger.info(' agentic: revision found no new docs — stopping') + stop_reason = 'no_new_docs' + break + # ══════════════════════════════════════════════════════════════════ + # Final Assembly + # ══════════════════════════════════════════════════════════════════ router_used = ( - 'agentic_llm' if state.selected_paths or state.kept_path_rows + 'agentic_llm' if any(t.has_content() for t in state.doc_trees.values()) else 'agentic_discovery_only' ) + # Collect referenced chunk IDs from all doc trees + all_refs: list[dict[str, str]] = [] + seen_ref_ids: set[str] = set() + for doc_tree in state.doc_trees.values(): + for ref in doc_tree.collect_referenced_ids(): + cid = ref.get('chunk_id', '') + if cid and cid not in seen_ref_ids: + seen_ref_ids.add(cid) + all_refs.append(ref) + + # Re-render final evidence (may have been updated in last revision) + if not evidence_text or evidence_text == '(no evidence collected)': + evidence_text = await _render_evidence( + db, + state.doc_trees, state.doc_id_to_name, + ) + + result = AgenticResult( + evidence_text=evidence_text, + answer_text=answer_text, + referenced_chunks=all_refs, + router_used=router_used, + ) + logger.info( - f'agentic retrieval DONE: {len(ranked_rows)} results, ' + f'agentic retrieval DONE: {len(all_refs)} referenced chunks, ' + f'evidence_text={len(evidence_text)} chars, ' + f'answer_text={len(answer_text)} chars, ' f'router={router_used}, steps={state.step_count}, ' f'stop_reason={stop_reason}, revisions={state.revision_count}, ' f'{state.elapsed_ms}ms' ) if trace_enabled: - await trace.complete(ranked_rows, router_used) - - return ranked_rows, router_used - - async def _execute_tool( - self, - db: AsyncSession, - action_type: ActionType, - state: AgentState, - config: AgentRunConfig, - **kwargs: Any, - ) -> ToolResult: - """Execute a single tool. Never raises.""" - from shared.services.retrieval.agentic import tools - - try: - if action_type == ActionType.BOTTOM_DISCOVERY: - return await tools.bottom_discovery(db, **kwargs) - - elif action_type == ActionType.KG_DOCUMENT_SELECT: - return await tools.kg_document_select( - db, - user_id=kwargs['user_id'], - namespace=kwargs['namespace'], - query=kwargs['query'], - llm_fn=kwargs.get('llm_fn'), - exclude_document_ids=list(state.excluded_doc_ids | set(kwargs.get('exclude_document_ids', []))), - ) - - elif action_type == ActionType.DOCUMENT_PATH_SELECT: - # Find which document to process next - doc = None - if state.pending_doc_index < len(state.selected_docs): - doc = state.selected_docs[state.pending_doc_index] - - if doc is None: - return ToolResult(status='error', error='no document to process') - - job_result_id = state.doc_job_map.get(doc.document_id, '') - if not job_result_id: - return ToolResult( - status='no_confident_match', - payload={'document_id': doc.document_id, 'reason': 'no job_result_id'}, - ) - - # Build exclude_paths for this doc from seen_section_keys - doc_exclude = { - key.split('::', 1)[1] - for key in state.seen_section_keys - if key.startswith(f'{doc.document_id}::') - } if state.seen_section_keys else None - - return await tools.document_path_select( - db, - user_id=kwargs['user_id'], - namespace=kwargs['namespace'], - query=kwargs['query'], - llm_fn=kwargs.get('llm_fn'), - document_id=doc.document_id, - job_result_id=job_result_id, - doc_name=doc.source_file_name or state.doc_id_to_name.get(doc.document_id, ''), - exclude_paths=doc_exclude, - ) - - elif action_type == ActionType.GREP_DOCUMENT_DISCOVER: - return await tools.grep_document_discover( - db, - user_id=kwargs['user_id'], - namespace=kwargs['namespace'], - query=kwargs['query'], - exclude_document_ids=list(state.excluded_doc_ids | set(kwargs.get('exclude_document_ids', []))), - ) - - elif action_type == ActionType.GRAPH_EXPAND_DOCS: - doc_ids = [d.document_id for d in state.selected_docs] - return await tools.graph_expand_docs( - db, - user_id=kwargs['user_id'], - namespace=kwargs['namespace'], - document_ids=doc_ids, - ) - - else: - return ToolResult(status='error', error=f'unknown action: {action_type}') - - except Exception as e: - logger.warning(f' agentic tool {action_type.value} raised: {e}') - return ToolResult(status='error', error=str(e)) + await trace.complete(all_refs, router_used) - async def _hydrate_and_rank( - self, - db: AsyncSession, - state: AgentState, - *, - user_id: str, - namespace: str, - top_k: int, - ) -> list[dict[str, Any]]: - """Fixed terminal step: hydrate selected paths + rank against discovery. - - Reuses _hydrate_paths_to_rows and _rank_candidates_by_path unchanged. - - TODO (Token Budget): Replace `ranked_rows[:top_k]` in - `_rank_candidates_by_path` with token-accumulation truncation - (tiktoken or character estimate) so the final result set respects - a configurable LLM context window budget rather than a fixed count. - """ - try: - - # Hydrate agent-selected paths - navigated_paths: list[dict[str, Any]] = [] - if state.selected_paths: - navigated_paths = await _hydrate_paths_to_rows( - db, - path_selections=state.selected_paths, - user_id=user_id, - namespace=namespace, - ) - - # Load importance scores for all candidate rows - all_candidates = state.discovery_paths + navigated_paths - if all_candidates: - importance_map = await _load_chunk_importance_scores( - db, user_id=user_id, namespace=namespace, - rows=all_candidates, - ) - for row in all_candidates: - chunk_id = row.get('chunk_id', '') - row['importance_raw_score'] = importance_map.get(chunk_id, 0.0) - - _normalize_row_scores( - all_candidates, - source_field='importance_raw_score', - target_field='importance_norm_score', - default=0.0, - ) - - # Rank: merge discovery + agent rows - ranked = _rank_candidates_by_path( - discovery_rows=state.discovery_paths, - routed_rows=navigated_paths, - top_k=top_k, - ) - - return ranked - - except Exception as e: - logger.error(f'agentic hydrate_and_rank failed: {e}') - # Last resort: return raw discovery rows - return state.discovery_paths[:top_k] + return result diff --git a/packages/shared-python/shared/services/retrieval/agentic/policy.py b/packages/shared-python/shared/services/retrieval/agentic/policy.py index 12e5afa62..e5a5c0dd4 100644 --- a/packages/shared-python/shared/services/retrieval/agentic/policy.py +++ b/packages/shared-python/shared/services/retrieval/agentic/policy.py @@ -1,317 +1,131 @@ -"""LLM-driven policy for agentic retrieval. +"""LLM answer-attempt tool for agentic retrieval. -Replaces the former RuleBasedPolicy. All control decisions are made by a -small LLM call so the agent can adapt to query complexity and KB topology -rather than following a fixed rule tree. +Provides ``attempt_answer()`` — a single LLM call that tries to answer +the user's query using the collected evidence. -Design: - - ``LLMPolicy.decide()`` is **async** — it calls the LLM to pick the - next action. - - On parse failure the agent terminates immediately (no hidden fallback). - - The prompt is compact: no raw chunk content, only state metadata. +Returns one of two outcomes: + - answer_text (non-empty) → the evidence was sufficient, answer is ready + - NOT_FOUND + reason → the evidence was insufficient, triggers a revision """ from __future__ import annotations import json +import os import re from typing import Any from loguru import logger -from shared.services.retrieval.agentic.types import ActionType, AgentRunConfig, AgentState +from shared.services.retrieval.agentic.types import AgentRunConfig, AgentState from shared.services.retrieval.llm_adapter import LLMFn -# ── Available actions presented to the LLM ─────────────────────────────────── -# NOTE: BOTTOM_DISCOVERY is intentionally excluded from this list. -# It is now a mandatory pre-step executed automatically by the orchestrator -# before the LLM decision loop begins. The LLM should never need to decide -# whether to run it — doing so wastes one LLM call per run. -_AVAILABLE_ACTIONS: list[dict[str, Any]] = [ - { - 'action': ActionType.KG_DOCUMENT_SELECT.value, - 'description': ( - 'Ask the LLM to select the most relevant documents from the Knowledge Graph ' - 'overview (doc summaries). Use after discovery.' - ), - 'when': 'discovery_done is true, kg_done is false', - }, - { - 'action': ActionType.DOCUMENT_PATH_SELECT.value, - 'description': ( - 'Drill into the next pending document\'s section tree and pick relevant ' - 'paths. The document is chosen automatically from selected_docs[pending_doc_index].' - ), - 'when': 'kg_done is true, there are pending documents to process', - }, - { - 'action': ActionType.GREP_DOCUMENT_DISCOVER.value, - 'description': ( - 'Run term/grep search across all documents to discover relevant doc IDs ' - 'when KG selection found nothing.' - ), - 'when': 'kg_done is true but selected_docs is empty', - }, - { - 'action': ActionType.GRAPH_EXPAND_DOCS.value, - 'description': ( - 'Expand via Knowledge Graph edge relationships to find related documents ' - 'not yet selected.' - ), - 'when': 'Optional deepening — use when current results seem insufficient', - }, - { - 'action': ActionType.DONE.value, - 'description': ( - 'Stop the agent loop and proceed to final hydration and ranking. ' - 'Use when you have sufficient evidence paths or the budget is nearly exhausted.' - ), - 'when': 'Sufficient paths collected, or further actions would not improve results', - }, -] - -_ACTIONS_BLOCK = '\n'.join( - f" {i+1}. \"{a['action']}\": {a['description']} [{a['when']}]" - for i, a in enumerate(_AVAILABLE_ACTIONS) -) - -_POLICY_PROMPT_TEMPLATE = """\ -You are a retrieval agent orchestrating document search for a RAG system. -Your job: choose the SINGLE best next action given the current state. - -QUERY: "{query}" - -CURRENT STATE: -{state_json} - -BUDGET: {elapsed_ms}ms elapsed of {budget_ms}ms max | Step {step} of {max_steps} - -AVAILABLE ACTIONS: -{actions_block} - -RULES: -1. Run kg_document_select when discovery_done is true and kg_done is false. -2. After kg select, run document_path_select for each pending document. -3. Call done when all pending documents are processed OR you have >= {min_evidence} evidence paths. -4. Only use grep_document_discover if kg_document_select found 0 documents. -5. Only use graph_expand_docs if you need more related docs after reviewing results. -Note: bottom_discovery is already executed automatically before this loop — do NOT attempt to call it. - -Return ONLY a JSON object, no markdown, no explanation: -{{"action": "", "reason": ""}} -""" - - -def _parse_action_from_response(response: str) -> dict[str, Any] | None: - """Extract the JSON decision object from the LLM response.""" - # Strip markdown code fences if present - text = response.strip() - text = re.sub(r'^```(?:json)?\s*', '', text, flags=re.MULTILINE) - text = re.sub(r'\s*```$', '', text, flags=re.MULTILINE) +def _parse_answer_response(text: str) -> dict[str, Any] | None: + """Extract a JSON answer object from LLM response text.""" text = text.strip() - - # Try to extract {...} block - match = re.search(r'\{[^{}]*\}', text, re.DOTALL) + try: + return json.loads(text) + except (json.JSONDecodeError, ValueError): + pass + match = re.search(r'\{.*\}', text, re.DOTALL) if match: try: - return json.loads(match.group(0)) - except json.JSONDecodeError: + return json.loads(match.group()) + except (json.JSONDecodeError, ValueError): pass - - # Try the whole text as JSON - try: - return json.loads(text) - except json.JSONDecodeError: - return None - - -class LLMPolicy: - """Async LLM-driven policy. One LLM call per agent step. - - Usage:: - - policy = LLMPolicy(llm_fn) - action_type, reason = await policy.decide(state, config, query=query) + return None + + +async def attempt_answer( + llm_fn: LLMFn, + *, + query: str, + evidence_text: str, + state: AgentState, + config: AgentRunConfig, +) -> tuple[str, str, str]: + """Attempt to answer the query using collected evidence. + + Returns (status, answer_text, reason) where: + - status='DONE', answer_text=, reason='' + → evidence was sufficient, answer is ready + - status='NOT_FOUND', answer_text='', reason= + → evidence was insufficient, reason is used as revision_hint """ - - def __init__(self, llm_fn: LLMFn, *, query: str = '') -> None: - self._llm_fn = llm_fn - self._query = query - - def build_prompt(self, state: AgentState, config: AgentRunConfig) -> str: - """Build the decision prompt. Public for test inspection.""" - state_data = state.state_summary() - - has_pending_docs = state.pending_doc_index < len(state.selected_docs) - state_data['has_pending_docs'] = has_pending_docs - state_json = json.dumps(state_data, ensure_ascii=False, indent=2) - - allowed_actions = [] - for action in _AVAILABLE_ACTIONS: - name = action['action'] - if name == ActionType.KG_DOCUMENT_SELECT.value and (not state.discovery_done or state.kg_done): - continue - if name == ActionType.DOCUMENT_PATH_SELECT.value and (not state.kg_done or not has_pending_docs): - continue - if name == ActionType.GREP_DOCUMENT_DISCOVER.value and (not state.kg_done or len(state.selected_docs) > 0): - continue - allowed_actions.append(action) - - actions_block = '\n'.join( - f" {i+1}. \"{a['action']}\": {a['description']} [{a['when']}]" - for i, a in enumerate(allowed_actions) - ) - - return _POLICY_PROMPT_TEMPLATE.format( - query=self._query, - state_json=state_json, - elapsed_ms=state.elapsed_ms, - budget_ms=config.latency_budget_ms, - step=state.step_count, - max_steps=config.max_steps, - actions_block=actions_block, - min_evidence=config.min_evidence_paths, - ) - - async def decide( - self, - state: AgentState, - config: AgentRunConfig, - ) -> tuple[ActionType | None, str]: - """Ask the LLM which action to take next. - - Returns ``(action_type, reason)`` or ``(None, reason)`` when the - LLM says ``done`` or when parsing fails (hard stop). - """ - prompt = self.build_prompt(state, config) - + prompt = _ATTEMPT_ANSWER_PROMPT.format( + query=query, + evidence_context=evidence_text, + revision_count=state.revision_count, + max_revisions=config.max_revisions, + ) + + verbose = os.environ.get('RETRIEVAL_AGENTIC_VERBOSE', '') == 'true' + if verbose: logger.info( - f' [LLMPolicy] step={state.step_count} calling LLM ' - f'(state: discovery={state.discovery_done}, kg={state.kg_done}, ' - f'docs={len(state.selected_docs)}, pending={state.pending_doc_index}, ' - f'paths={len(state.selected_paths)})' + f'[attempt_answer PROMPT]\n' + f'{prompt}' ) - # ── Verbose prompt logging (controlled by env) ── - import os - if os.environ.get('RETRIEVAL_AGENTIC_VERBOSE', 'false') == 'true': - logger.info( - f'\n{"="*60}\n' - f'[LLMPolicy PROMPT step={state.step_count}]\n' - f'{prompt}\n' - f'{"="*60}' - ) - - raw_response = await self._llm_fn(prompt) + raw_response = await llm_fn(prompt) + logger.info(f' [attempt_answer] raw={repr(raw_response[:300])}') + if verbose: logger.info( - f' [LLMPolicy] raw_response={repr(raw_response[:200])}' + f'[attempt_answer RESPONSE]\n' + f'{raw_response}' ) - if os.environ.get('RETRIEVAL_AGENTIC_VERBOSE', 'false') == 'true': - logger.info( - f'\n{"="*60}\n' - f'[LLMPolicy RESPONSE step={state.step_count}]\n' - f'{raw_response}\n' - f'{"="*60}' - ) - - if not raw_response.strip(): - logger.warning(' [LLMPolicy] empty response → stopping agent') - return None, 'empty LLM response' - - parsed = _parse_action_from_response(raw_response) - if not parsed: - logger.warning( - f' [LLMPolicy] could not parse JSON from response: {repr(raw_response[:300])} → stopping agent' - ) - return None, f'parse_error: {raw_response[:100]}' + parsed = _parse_answer_response(raw_response) + if not parsed: + # Parse error: treat raw text as best-effort answer + return 'DONE', raw_response.strip(), 'parse_error — treating raw response as answer' - action_str = str(parsed.get('action', '')).strip() - reason = str(parsed.get('reason', '')).strip() + status = str(parsed.get('status', 'DONE')).strip().upper() + answer = str(parsed.get('answer', '')).strip() + reason = str(parsed.get('reason', '')).strip() - logger.info(f' [LLMPolicy] decided action="{action_str}" reason="{reason}"') - - if action_str == ActionType.DONE.value: - return ActionType.DONE, reason - - # Validate against whitelist - try: - action_type = ActionType(action_str) - except ValueError: - logger.warning( - f' [LLMPolicy] unknown action "{action_str}" → stopping agent' - ) - return None, f'unknown_action: {action_str}' + if status == 'NOT_FOUND': + return 'NOT_FOUND', '', reason or 'LLM returned NOT_FOUND without reason' - return action_type, reason - - async def attempt_answer( - self, - state: AgentState, - config: AgentRunConfig, - ranked_rows: list[dict[str, Any]], - ) -> tuple[str, str]: - """Three-state verdict: is the evidence sufficient? - - Returns (verdict, reason) where verdict is one of: - - 'DONE': evidence is sufficient, stop searching - - 'NOT_SUFFICIENT': partial match, need more evidence - - 'NOT_FOUND': no relevant evidence found at all - """ - # Build evidence summary (paths + previews, not full content) - evidence_lines: list[str] = [] - for i, row in enumerate(ranked_rows[:20]): # cap to avoid huge prompt - path = row.get('section_path') or row.get('source_chunk_path') or '' - content_preview = str(row.get('content', ''))[:150] - score = round(float(row.get('score', 0.0) or 0.0), 3) - evidence_lines.append( - f' {i+1}. path="{path}" score={score}\n' - f' preview: {content_preview}' - ) - evidence_text = '\n'.join(evidence_lines) or '(no evidence collected)' - - prompt = _ATTEMPT_ANSWER_PROMPT.format( - query=self._query, - evidence_count=len(ranked_rows), - evidence_summary=evidence_text, - revision_count=state.revision_count, - max_revisions=config.max_revisions, - ) - - raw_response = await self._llm_fn(prompt) - logger.info(f' [LLMPolicy.attempt_answer] raw={repr(raw_response[:200])}') - - parsed = _parse_action_from_response(raw_response) - if not parsed: - return 'DONE', 'parse_error — treating as done' - - verdict = str(parsed.get('verdict', 'DONE')).strip().upper() - reason = str(parsed.get('reason', '')).strip() - - if verdict not in ('DONE', 'NOT_SUFFICIENT', 'NOT_FOUND'): - verdict = 'DONE' - - return verdict, reason + # Any status other than NOT_FOUND → treat as DONE + if not answer: + answer = reason or '(empty answer)' + return 'DONE', answer, '' _ATTEMPT_ANSWER_PROMPT = """\ -You are evaluating whether the collected evidence can answer the user's query. +You are a knowledge retrieval assistant. Answer the user's query based +STRICTLY on the provided evidence. Do NOT use any external knowledge. QUERY: "{query}" -EVIDENCE ({evidence_count} items, showing top 20): -{evidence_summary} +EVIDENCE CONTEXT: +The following evidence is organized by document in a unified hierarchy. +Each document shows its structural outline (section titles + summaries) +with retrieved content (┈ lines) inline under the relevant sections. -REVISION: {revision_count} of {max_revisions} revisions used. - -Evaluate the evidence and return ONE verdict: -- "DONE": The evidence is sufficient to answer the query. Use this if the main points are covered. -- "NOT_SUFFICIENT": Partial match — some relevant info found but key aspects are missing. Only use if more searching could realistically help. -- "NOT_FOUND": The evidence is completely irrelevant to the query. Only use if nothing matches at all. +{evidence_context} -When in doubt, prefer DONE — avoid unnecessary extra search rounds. +REVISION: {revision_count} of {max_revisions} revisions used. -Return ONLY a JSON object: -{{"verdict": "DONE", "reason": "one sentence explanation"}} +INSTRUCTIONS: +1. If the evidence contains enough information to answer the query, + compose a clear and comprehensive answer. Return: + {{"status": "DONE", "answer": ""}} + +2. If the evidence does NOT contain sufficient information to answer + the query, return NOT_FOUND with a specific reason explaining what + information is missing. This reason will be used to guide the next + search round. Return: + {{"status": "NOT_FOUND", "reason": ""}} + +IMPORTANT: +- Base your judgment ONLY on the actual retrieved content (┈ lines), + not just section titles or summaries. +- Be specific in your NOT_FOUND reason — mention exactly what data, + section, or detail you expected but didn't find. +- When the evidence partially covers the query, still return DONE with + the available information and note any gaps in your answer. + +Return ONLY a JSON object, no other text. """ diff --git a/packages/shared-python/shared/services/retrieval/agentic/tools.py b/packages/shared-python/shared/services/retrieval/agentic/tools.py index 52c7645d3..c337d2e08 100644 --- a/packages/shared-python/shared/services/retrieval/agentic/tools.py +++ b/packages/shared-python/shared/services/retrieval/agentic/tools.py @@ -4,10 +4,6 @@ 1. Calls existing functions from channels.py, agent_navigate.py, app_service.py 2. Returns a unified ToolResult 3. Never raises — errors are captured in ToolResult.error - -No new retrieval algorithms, ranking strategies, or prompts. -LLM calls inside kg_document_select / document_path_select reuse -the exact same prompts and parsing logic from agent_navigate.py. """ from __future__ import annotations @@ -19,18 +15,18 @@ from sqlalchemy.ext.asyncio import AsyncSession from shared.models.database.document import Document -from shared.services.retrieval.agentic.types import ToolResult +from shared.services.retrieval.agentic.types import DocTreeNode, ToolResult from shared.services.retrieval.agent_navigate import ( _build_knowledge_map_overview, _expand_by_edges, _format_items_for_llm, _grep_discover_document_ids, _load_child_sections, - _parse_chunk_path_selections, _parse_json_array, + _parse_scope_nav_response, _SCOPE_NAV_PROMPT, + _DISCOVERY_SELECT_PROMPT, _FILE_SELECT_PROMPT, - _default_confidence_for_rank, ) from shared.services.retrieval.app_service import ( _CHANNEL_WEIGHT_CONTENT, @@ -40,6 +36,7 @@ _merge_same_section_rows, _normalize_row_scores, _resolve_allowed_chunk_types, + hydrate_connected_target_rows, merge_channels_rrf, ) from shared.services.retrieval.channels import content_channel, path_channel, term_channel @@ -67,11 +64,7 @@ async def bottom_discovery( internal_recall_k: int | None = None, **_kwargs: Any, ) -> ToolResult: - """Run 3-channel BM25 discovery + RRF fusion. - - Reuses: channels.path_channel, content_channel, term_channel, - app_service.merge_channels_rrf, _merge_same_section_rows. - """ + """Run 3-channel BM25 discovery + RRF fusion.""" t0 = time.monotonic() try: allowed_chunk_types = _resolve_allowed_chunk_types(data_type) @@ -106,7 +99,7 @@ async def bottom_discovery( signal_paths=signal_paths, filter_mode=filter_mode, ) - # RRF fusion — same logic as app_service + # RRF fusion default_weights = { 'path': _CHANNEL_WEIGHT_PATH, 'content': _CHANNEL_WEIGHT_CONTENT, @@ -178,11 +171,7 @@ async def kg_document_select( exclude_document_ids: list[str], **_kwargs: Any, ) -> ToolResult: - """Select candidate documents from document-level KG. - - Reuses: agent_navigate._build_knowledge_map_overview, _parse_json_array. - Same LLM prompt as agent_navigate._FILE_SELECT_PROMPT. - """ + """Select candidate documents from document-level KG.""" t0 = time.monotonic() try: overview_text, doc_id_to_name = await _build_knowledge_map_overview( @@ -204,7 +193,6 @@ async def kg_document_select( latency_ms=latency, ) - # LLM file selection — same prompt as agent_navigate file_prompt = _FILE_SELECT_PROMPT.format( overview=overview_text, query=query, ) @@ -239,7 +227,7 @@ async def kg_document_select( candidate_docs.append({ 'document_id': did, 'source_file_name': doc_id_to_name.get(did, ''), - 'confidence': 0.8, + 'confidence': 1.0, 'reason': 'LLM selected from KG overview', 'source': 'kg_llm_select', }) @@ -261,39 +249,6 @@ async def kg_document_select( return ToolResult(status='error', error=str(e), latency_ms=latency) -# --------------------------------------------------------------------------- -# Tool: document_path_select -# --------------------------------------------------------------------------- - -async def document_path_select( - db: AsyncSession, - *, - user_id: str, - namespace: str, - query: str, - llm_fn: LLMFn | None, - document_id: str, - job_result_id: str, - doc_name: str = '', - max_chunks_per_file: int = 15, - exclude_paths: set[str] | None = None, - **_kwargs: Any, -) -> ToolResult: - """Document entry point for agentic scope navigation.""" - if llm_fn is None: - return ToolResult( - status='no_confident_match', - payload={'document_id': document_id, 'reason': 'LLM not available'}, - latency_ms=0, - ) - return await scope_navigate( - db, document_id=document_id, job_result_id=job_result_id, - query=query, llm_fn=llm_fn, doc_name=doc_name, - scope_path=None, max_select=max_chunks_per_file, - exclude_paths=exclude_paths, - ) - - # --------------------------------------------------------------------------- # Tool: grep_document_discover # --------------------------------------------------------------------------- @@ -307,10 +262,7 @@ async def grep_document_discover( exclude_document_ids: list[str], **_kwargs: Any, ) -> ToolResult: - """Discover documents via term search (GREP). - - Reuses: agent_navigate._grep_discover_document_ids. - """ + """Discover documents via term search (GREP).""" t0 = time.monotonic() try: grep_doc_ids = await _grep_discover_document_ids( @@ -326,7 +278,6 @@ async def grep_document_discover( latency_ms=latency, ) - # Load doc names and job_result_ids doc_stmt = ( select(Document.document_id, Document.source_file_name, Document.current_job_result_id) .where(Document.document_id.in_(grep_doc_ids)) @@ -368,10 +319,7 @@ async def graph_expand_docs( document_ids: list[str], **_kwargs: Any, ) -> ToolResult: - """Expand document set via KG edge traversal. - - Reuses: agent_navigate._expand_by_edges. - """ + """Expand document set via KG edge traversal.""" t0 = time.monotonic() try: expanded_ids = await _expand_by_edges( @@ -387,7 +335,6 @@ async def graph_expand_docs( latency_ms=latency, ) - # Load names and job maps for new docs doc_stmt = ( select(Document.document_id, Document.source_file_name, Document.current_job_result_id) .where(Document.document_id.in_(new_ids)) @@ -418,100 +365,242 @@ async def graph_expand_docs( # --------------------------------------------------------------------------- -# Tool: scope_navigate (Unified recursive navigation) +# Tool: scope_navigate_step (single-step navigation) # --------------------------------------------------------------------------- -async def scope_navigate( +_LLM_MODE_TO_HYDRATE: dict[str, str] = { + 'all': 'chunks', + 'image': 'image_only', + 'table': 'table_only', +} + +async def scope_navigate_step( db: AsyncSession, *, document_id: str, job_result_id: str, query: str, llm_fn: LLMFn, + user_id: str, + namespace: str, doc_name: str = '', scope_path: str | None = None, - max_select: int = 15, exclude_paths: set[str] | None = None, -) -> ToolResult: - """Unified document-internal navigation tool. - - 1. Loads 2 levels of child sections under scope_path - 2. Applies overflow guard (drops summaries if needed) - 3. LLM selects most relevant items - 4. Returns selected section paths directly; each path hydrates the - corresponding section subtree. + revision_hint: str | None = None, +) -> tuple[DocTreeNode, list[dict]]: + """Single navigation step — one LLM call, no recursion. + + Returns: + - node: DocTreeNode with outline_items (current scope local items only) + and leaf_content (hydrated leaf selections) + - pending: list of {path, confidence, mode} for non-leaf selections + (orchestrator queues these for further drill-down) """ - t0 = time.monotonic() + from shared.services.retrieval.app_service import _hydrate_paths_to_rows + + empty = DocTreeNode.empty(scope_path) + try: + # 1. Load continuous context tree items = await _load_child_sections( db, document_id, job_result_id, scope_path, exclude_paths=exclude_paths, ) if not items: - latency = int((time.monotonic() - t0) * 1000) - return ToolResult( - status='no_items', - payload={'document_id': document_id, 'scope_path': scope_path}, - latency_ms=latency, - ) + return empty, [] + + # 2. Build selectable index (only current-scope items with summary) + selectable = {item['path']: item for item in items if item.get('show_summary', True)} + # 3. Format full tree and call LLM text, overflowed = _format_items_for_llm(items) + scope_header = ( + f'Current scope: navigating into "{scope_path}"' + if scope_path else + 'Current scope: root (document top level)' + ) prompt = _SCOPE_NAV_PROMPT.format( doc_name=doc_name or document_id, doc_id=document_id, - scope_label=scope_path or 'root', + scope_header=scope_header, items_overview=text, query=query, - max_select=max_select, ) - - valid_paths = {item['path'] for item in items} + if revision_hint: + prompt += ( + f'\n\nIMPORTANT: Previous round feedback: ' + f'"{revision_hint}". Select specific sections this time.' + ) response = await llm_fn(prompt) - selected = _parse_chunk_path_selections(response) + selections = _parse_scope_nav_response(response) + + logger.info( + f' scope_navigate_step scope={scope_path or "root"}: ' + f'selections={len(selections)}, selectable={len(selectable)}, ' + f'overflowed={overflowed}' + ) - accepted: list[dict[str, Any]] = [] - for item in selected: - path = str(item.get('path') or '').strip() - if path not in valid_paths: + # 4. Build node with LOCAL items only (no ancestors/siblings) + node = DocTreeNode(scope_path=scope_path) + local_items = [item for item in items if item.get('show_summary', True)] + node.outline_items = local_items + + # 5. Dispatch selections (guard: never re-select scope_path itself) + valid_selections = [ + s for s in selections + if s['path'] in selectable and s['path'] != scope_path + ] + + pending: list[dict] = [] + for sel in valid_selections: + path = sel['path'] + conf = sel.get('confidence', 0.7) + item = selectable[path] + node.confidence[path] = conf + + if item.get('is_leaf'): + # Leaf → hydrate chunks + hydrate_mode = _LLM_MODE_TO_HYDRATE.get( + str(sel.get('mode', 'all')).strip().lower(), 'chunks' + ) + chunks = await _hydrate_paths_to_rows( + db, + path_selections=[ + {'path': path, 'confidence': conf, 'hydrate_mode': hydrate_mode} + ], + user_id=user_id, + namespace=namespace, + document_id=document_id, + ) + # Also hydrate connected targets (image/table chunks referenced via connect_to) + if chunks: + connected = await hydrate_connected_target_rows( + db=db, + rows=chunks, + exclude_document_ids=[], + exclude_sections=[], + ) + if connected: + chunks = chunks + connected + node.leaf_content[path] = chunks + else: + # Non-leaf → return as pending for orchestrator to queue + pending.append(sel) + + return node, pending + + except Exception as e: + logger.error(f' scope_navigate_step failed for doc={document_id}: {e}') + return empty, [] + + +# --------------------------------------------------------------------------- +# Tool: discovery_select_step (post-navigation discovery selection) +# --------------------------------------------------------------------------- + +_MAX_DISCOVERY_PER_DOC = 3 + + +async def discovery_select_step( + db: AsyncSession, + *, + document_id: str, + query: str, + llm_fn: LLMFn, + user_id: str, + namespace: str, + doc_name: str = '', + discovery_hints: list[dict[str, Any]], +) -> DocTreeNode: + """Post-navigation discovery selection step. + + After BFS navigation exhausts for a document, present discovery-found + section paths (from bottom_discovery BM25) to the LLM for selection. + Selected paths are hydrated as leaf content. + + For B-class documents (discovery-only, not KG-selected), this is the + only navigation step — no prior BFS. + """ + from shared.services.retrieval.app_service import _hydrate_paths_to_rows + + node = DocTreeNode(scope_path=None) + if not discovery_hints: + return node + + # Limit hints per document + hints = discovery_hints[:_MAX_DISCOVERY_PER_DOC] + + t0 = time.monotonic() + try: + # 1. Format hints for LLM + hint_lines: list[str] = [] + hint_by_path: dict[str, dict] = {} + for h in hints: + sp = h.get('section_path', '') + if not sp: continue - confidence = item.get('confidence') - if confidence is None: - confidence = _default_confidence_for_rank(len(accepted)) - hydrate_mode = item.get('hydrate_mode', 'chunks') - accepted.append({'path': path, 'confidence': confidence, 'hydrate_mode': hydrate_mode}) - if len(accepted) >= max_select: - break + title = sp.rsplit(' / ', 1)[-1] if ' / ' in sp else sp + summary = h.get('summary', '') or '' + hint_lines.append(f'▸ path="{sp}" {title} [Leaf]') + if summary: + clipped = summary[:300] + hint_lines.append(f' {clipped}') + hint_by_path[sp] = h + + if not hint_lines: + return node + + items_text = '\n'.join(hint_lines) + prompt = _DISCOVERY_SELECT_PROMPT.format( + doc_name=doc_name or document_id, + items=items_text, + query=query, + ) + response = await llm_fn(prompt) + selections = _parse_scope_nav_response(response) - latency = int((time.monotonic() - t0) * 1000) - - if not accepted: - return ToolResult( - status='no_confident_match', - payload={'document_id': document_id, 'reason': 'no path matches query intent'}, - latency_ms=latency, + logger.info( + f' discovery_select_step doc="{doc_name}": ' + f'hints={len(hints)} selections={len(selections)}' + ) + + # 2. Hydrate selected paths + valid_selections = [s for s in selections if s['path'] in hint_by_path] + for sel in valid_selections: + path = sel['path'] + conf = sel.get('confidence', 0.7) + hydrate_mode = _LLM_MODE_TO_HYDRATE.get( + str(sel.get('mode', 'all')).strip().lower(), 'chunks' ) + node.confidence[path] = conf + + chunks = await _hydrate_paths_to_rows( + db, + path_selections=[ + {'path': path, 'confidence': conf, 'hydrate_mode': hydrate_mode} + ], + user_id=user_id, + namespace=namespace, + document_id=document_id, + ) + if chunks: + connected = await hydrate_connected_target_rows( + db=db, + rows=chunks, + exclude_document_ids=[], + exclude_sections=[], + ) + if connected: + chunks = chunks + connected + node.leaf_content[path] = chunks + latency = int((time.monotonic() - t0) * 1000) logger.info( - f" agentic.scope_navigate: {len(accepted)} section paths selected, " - f"status=selected_paths, overflowed={overflowed}" + f' discovery_select_step done: hydrated={len(node.leaf_content)} ' + f'latency={latency}ms' ) + return node - return ToolResult( - status='selected_paths', - payload={ - 'document_id': document_id, - 'selected_paths': accepted, - 'scope_path': scope_path, - 'overflowed': overflowed, - }, - latency_ms=latency, - ) except Exception as e: - latency = int((time.monotonic() - t0) * 1000) - logger.error(f' agentic.scope_navigate failed for doc={document_id}: {e}') - return ToolResult( - status='error', - payload={'document_id': document_id}, - error=str(e), - latency_ms=latency, - ) + logger.error(f' discovery_select_step failed for doc={document_id}: {e}') + return node diff --git a/packages/shared-python/shared/services/retrieval/agentic/trace.py b/packages/shared-python/shared/services/retrieval/agentic/trace.py index ce320eef6..b504971eb 100644 --- a/packages/shared-python/shared/services/retrieval/agentic/trace.py +++ b/packages/shared-python/shared/services/retrieval/agentic/trace.py @@ -15,7 +15,7 @@ from loguru import logger from sqlalchemy.ext.asyncio import AsyncSession -from shared.services.retrieval.agentic.types import ActionType, AgentRunConfig, ToolResult +from shared.services.retrieval.agentic.types import AgentRunConfig, ToolResult def _now_utc() -> datetime: @@ -101,7 +101,7 @@ async def create_run(self) -> None: def record_step( self, - action_type: ActionType, + action_type: str, result: ToolResult, *, decision_reason: str = '', @@ -109,7 +109,7 @@ def record_step( """Buffer a step record. Flushed on complete().""" self._steps.append({ 'step_index': len(self._steps), - 'action_type': action_type.value, + 'action_type': action_type, 'action_input': {'decision_reason': decision_reason} if decision_reason else {}, 'observation_status': result.status, 'observation_payload_keys': list(result.payload.keys()) if result.payload else [], diff --git a/packages/shared-python/shared/services/retrieval/agentic/types.py b/packages/shared-python/shared/services/retrieval/agentic/types.py index 4877886de..e9d5f404c 100644 --- a/packages/shared-python/shared/services/retrieval/agentic/types.py +++ b/packages/shared-python/shared/services/retrieval/agentic/types.py @@ -8,30 +8,15 @@ import time from dataclasses import dataclass, field -from enum import Enum from typing import Any -class ActionType(str, Enum): - """Whitelisted action space for the retrieval agent.""" - BOTTOM_DISCOVERY = 'bottom_discovery' - KG_DOCUMENT_SELECT = 'kg_document_select' - DOCUMENT_PATH_SELECT = 'document_path_select' - GREP_DOCUMENT_DISCOVER = 'grep_document_discover' - GRAPH_EXPAND_DOCS = 'graph_expand_docs' - DONE = 'done' # Explicit termination signal from LLMPolicy - - @dataclass class AgentRunConfig: """Budget and limit configuration for a single agent run.""" - max_steps: int = 10 - max_docs: int = 0 # 0 = no limit, LLM decides autonomously - max_path_expansions: int = 2 - max_doc_retries: int = 2 - max_revisions: int = 2 # max attempt_answer → revise cycles + max_revisions: int = 2 # max attempt_answer → revision cycles + max_nav_depth: int = 3 # max scope_navigate recursion depth latency_budget_ms: int = 12000 - min_evidence_paths: int = 1 @dataclass @@ -47,6 +32,118 @@ class ToolResult: error: str | None = None +@dataclass +class DocTreeNode: + """Unified navigation result tree for one document. + + Produced by ``scope_navigate_step``. Captures the full + navigation outcome for rendering as a single hierarchy: + + - ``outline_items``: section tree items at this scope level + - ``leaf_content``: hydrated chunk rows keyed by section path + (leaf selections from LLM) + - ``children``: recursive child trees keyed by section path + (non-leaf selections, populated by orchestrator BFS queue) + - ``confidence``: per-selection confidence for trimming + """ + scope_path: str | None = None + + # Outline items at this level (title + summary) + outline_items: list[dict[str, Any]] = field(default_factory=list) + + # Leaf results, keyed by section path: + leaf_content: dict[str, list[dict[str, Any]]] = field(default_factory=dict) + children: dict[str, 'DocTreeNode'] = field(default_factory=dict) + + # Confidence per selection (for trimming) + confidence: dict[str, float] = field(default_factory=dict) + + @staticmethod + def empty(scope_path: str | None = None) -> 'DocTreeNode': + return DocTreeNode(scope_path=scope_path) + + def has_content(self) -> bool: + """Check if this tree has any meaningful content (outline, chunks, or children).""" + if self.outline_items: + return True + if self.leaf_content: + return True + if self.children: + return any(c.has_content() for c in self.children.values()) + return False + + def collect_all_paths(self, doc_id: str) -> set[str]: + """Recursively collect paths of actually-explored sections. + + Only ``leaf_content`` and ``children`` represent sections whose + content was retrieved. ``outline_items`` are structural context + (titles/summaries shown to the LLM) and must NOT be masked — + otherwise revision rounds see 0 candidates and can't re-navigate. + """ + paths: set[str] = set() + if self.scope_path: + paths.add(f'{doc_id}::{self.scope_path}') + for path in self.leaf_content: + paths.add(f'{doc_id}::{path}') + for path, child in self.children.items(): + paths.add(f'{doc_id}::{path}') + paths.update(child.collect_all_paths(doc_id)) + return paths + + def flatten_chunk_rows(self) -> list[dict[str, Any]]: + """Recursively collect all hydrated chunk rows (document order).""" + rows: list[dict[str, Any]] = [] + for chunks in self.leaf_content.values(): + rows.extend(chunks) + for child in self.children.values(): + rows.extend(child.flatten_chunk_rows()) + return rows + + def collect_referenced_ids(self) -> list[dict[str, str]]: + """Extract minimal chunk references from all hydrated leaves. + + Returns deduplicated list of {chunk_id, document_id, chunk_type, + section_path, file_path, job_id} for hit stats and frontend display. + """ + refs: list[dict[str, str]] = [] + seen: set[str] = set() + for row in self.flatten_chunk_rows(): + cid = row.get('chunk_id', '') + if cid and cid not in seen: + seen.add(cid) + refs.append({ + 'chunk_id': cid, + 'document_id': row.get('document_id', ''), + 'chunk_type': row.get('chunk_type', ''), + 'section_path': row.get('section_path', ''), + 'file_path': row.get('file_path', ''), + 'job_id': row.get('job_id', ''), + }) + return refs + + def merge(self, other: 'DocTreeNode') -> None: + """Additive merge for revision cycles. + + Merges outline items, leaf content, children, and confidence from + ``other`` into this node. Existing data is preserved; new data is + added. For confidence values, the higher value wins. + """ + existing_paths = {item['path'] for item in self.outline_items} + for item in other.outline_items: + if item.get('path', '') not in existing_paths: + self.outline_items.append(item) + for path, chunks in other.leaf_content.items(): + if path not in self.leaf_content: + self.leaf_content[path] = chunks + for path, child in other.children.items(): + if path in self.children: + self.children[path].merge(child) + else: + self.children[path] = child + for path, conf in other.confidence.items(): + self.confidence[path] = max(self.confidence.get(path, 0), conf) + + @dataclass class CandidateDoc: """A document selected by kg_document_select.""" @@ -54,199 +151,56 @@ class CandidateDoc: source_file_name: str = '' confidence: float = 0.0 reason: str = '' - source: str = '' # 'kg_llm_select' | 'grep' | 'edge_expand' + source: str = '' # 'kg_llm_select' | 'grep' | 'edge_expand' | 'discovery_hint' + + +@dataclass +class AgenticResult: + """Output of agentic retrieval. + + - ``evidence_text``: complete hierarchical context for LLM answering + (rendered doc tree with outline + leaf content + inline tables) + - ``answer_text``: LLM-generated answer to the query based on the + evidence. Empty string when the evidence was insufficient + (NOT_FOUND) and max revisions were exhausted. + - ``referenced_chunks``: minimal chunk references for hit stats + and frontend display (chunk_id, document_id, chunk_type, etc.) + - ``router_used``: routing path identifier + """ + evidence_text: str + answer_text: str = '' + referenced_chunks: list[dict[str, str]] = field(default_factory=list) + router_used: str = 'agentic_discovery_only' @dataclass class AgentState: - """Mutable state carried through the agent loop. + """Mutable state carried through the 2-phase orchestrator. - Updated by ``state.apply(action_type, tool_result)`` after each step. + Phase 1: Document selection (discovery + KG) + Phase 2: Per-document navigation (scope_navigate_step per doc) + Phase 3: Assembly + final verdict """ # Timing start_time: float = field(default_factory=time.monotonic) step_count: int = 0 - # Discovery results (from bottom_discovery) - discovery_paths: list[dict[str, Any]] = field(default_factory=list) + # Phase 1: Discovery discovery_top_doc_ids: list[str] = field(default_factory=list) - discovery_done: bool = False - # KG document selection + # Phase 1: KG document selection selected_docs: list[CandidateDoc] = field(default_factory=list) - excluded_doc_ids: set[str] = field(default_factory=set) - doc_retry_count: int = 0 - kg_done: bool = False - pending_doc_index: int = 0 - - # Document path selection - selected_paths: list[dict[str, Any]] = field(default_factory=list) - path_expansion_count: int = 0 - - # Last observation (used by policy to decide next action) - last_observation: ToolResult | None = None - - # Context passed through doc_id_to_name: dict[str, str] = field(default_factory=dict) doc_job_map: dict[str, str] = field(default_factory=dict) - # Revision / three-state fields + # Phase 2: Per-document navigation results + doc_trees: dict[str, DocTreeNode] = field(default_factory=dict) # doc_id → DocTreeNode + + # Revision state revision_count: int = 0 ever_explored_doc_ids: set[str] = field(default_factory=set) seen_section_keys: set[str] = field(default_factory=set) # "{doc_id}::{section_path}" - kept_path_rows: list[dict[str, Any]] = field(default_factory=list) @property def elapsed_ms(self) -> int: return int((time.monotonic() - self.start_time) * 1000) - - def state_summary(self) -> dict[str, Any]: - """Produce a concise state snapshot for the LLMPolicy prompt. - - Only includes fields the LLM needs for decision-making — never - exposes raw chunk content or internal IDs verbatim. - """ - selected_doc_summaries = [ - { - 'document_id': d.document_id, - 'name': d.source_file_name or '(unnamed)', - 'confidence': round(d.confidence, 2), - 'source': d.source, - } - for d in self.selected_docs - ] - selected_path_summaries = [ - { - 'path': p.get('path', ''), - 'confidence': round(float(p.get('confidence', 0.0) or 0.0), 2), - } - for p in self.selected_paths[:10] # cap to avoid huge prompts - ] - last_obs = None - if self.last_observation: - last_obs = { - 'status': self.last_observation.status, - 'payload_keys': list(self.last_observation.payload.keys()), - 'error': self.last_observation.error, - } - return { - 'step': self.step_count, - 'discovery_done': self.discovery_done, - 'discovery_candidates': len(self.discovery_paths), - 'discovery_top_doc_ids': self.discovery_top_doc_ids[:5], - 'kg_done': self.kg_done, - 'selected_docs': selected_doc_summaries, - 'pending_doc_index': self.pending_doc_index, - 'selected_paths_count': len(self.selected_paths), - 'selected_paths': selected_path_summaries, - 'doc_retry_count': self.doc_retry_count, - 'revision_count': self.revision_count, - 'explored_doc_count': len(self.ever_explored_doc_ids), - 'kept_rows_count': len(self.kept_path_rows), - 'last_observation': last_obs, - } - - def apply(self, action_type: ActionType, result: ToolResult) -> None: - """Update state based on action and its result.""" - self.last_observation = result - - if action_type == ActionType.BOTTOM_DISCOVERY: - self.discovery_done = True - if result.status != 'error': - self.discovery_paths = result.payload.get('fused_rows', []) - self.discovery_top_doc_ids = result.payload.get('top_doc_ids', []) - - elif action_type == ActionType.KG_DOCUMENT_SELECT: - self.kg_done = True - if result.status == 'selected_docs': - new_docs = result.payload.get('candidate_docs', []) - for doc_data in new_docs: - if isinstance(doc_data, CandidateDoc): - self.selected_docs.append(doc_data) - elif isinstance(doc_data, dict): - self.selected_docs.append(CandidateDoc( - document_id=doc_data.get('document_id', ''), - source_file_name=doc_data.get('source_file_name', ''), - confidence=doc_data.get('confidence', 0.0), - reason=doc_data.get('reason', ''), - source=doc_data.get('source', ''), - )) - self.doc_id_to_name.update(result.payload.get('doc_id_to_name', {})) - self.doc_job_map.update(result.payload.get('doc_job_map', {})) - - # Merge discovery hints - existing_ids = {d.document_id for d in self.selected_docs} - for did in self.discovery_top_doc_ids: - if did not in existing_ids and did not in self.excluded_doc_ids: - self.selected_docs.append(CandidateDoc( - document_id=did, - source_file_name=self.doc_id_to_name.get(did, ''), - confidence=0.5, # Lower than LLM's 0.8 - reason='Bottom discovery hit', - source='discovery_hint', - )) - existing_ids.add(did) - - elif action_type == ActionType.DOCUMENT_PATH_SELECT: - if result.status == 'selected_paths': - doc_id = result.payload.get('document_id', '') - new_paths = result.payload.get('selected_paths', []) - for p in new_paths: - p['document_id'] = doc_id - self.selected_paths.extend(new_paths) - self.pending_doc_index += 1 - if doc_id: - self.ever_explored_doc_ids.add(doc_id) - elif result.status == 'no_items': - doc_id = result.payload.get('document_id', '') - self.pending_doc_index += 1 - if doc_id: - self.ever_explored_doc_ids.add(doc_id) - elif result.status == 'need_more_docs': - failed_doc_id = result.payload.get('document_id', '') - if failed_doc_id: - self.excluded_doc_ids.add(failed_doc_id) - self.doc_retry_count += 1 - self.kg_done = False # allow re-entry to KG select - elif result.status == 'no_confident_match': - doc_id = result.payload.get('document_id', '') - self.pending_doc_index += 1 - if doc_id: - self.ever_explored_doc_ids.add(doc_id) - elif result.status == 'error': - doc_id = result.payload.get('document_id', '') - self.pending_doc_index += 1 - if doc_id: - self.ever_explored_doc_ids.add(doc_id) - - elif action_type == ActionType.GREP_DOCUMENT_DISCOVER: - if result.status == 'discovered_docs': - grep_doc_ids = result.payload.get('document_ids', []) - doc_id_to_name = result.payload.get('doc_id_to_name', {}) - for did in grep_doc_ids: - if did not in self.excluded_doc_ids: - existing_ids = {d.document_id for d in self.selected_docs} - if did not in existing_ids: - self.selected_docs.append(CandidateDoc( - document_id=did, - source_file_name=doc_id_to_name.get(did, ''), - source='grep', - )) - self.doc_id_to_name.update(doc_id_to_name) - self.doc_job_map.update(result.payload.get('doc_job_map', {})) - - elif action_type == ActionType.GRAPH_EXPAND_DOCS: - if result.status == 'expanded_docs': - expanded_ids = result.payload.get('document_ids', []) - doc_id_to_name = result.payload.get('doc_id_to_name', {}) - for did in expanded_ids: - if did not in self.excluded_doc_ids: - existing_ids = {d.document_id for d in self.selected_docs} - if did not in existing_ids: - self.selected_docs.append(CandidateDoc( - document_id=did, - source_file_name=doc_id_to_name.get(did, ''), - source='edge_expand', - )) - self.doc_id_to_name.update(doc_id_to_name) - self.doc_job_map.update(result.payload.get('doc_job_map', {})) diff --git a/packages/shared-python/shared/services/retrieval/app_service.py b/packages/shared-python/shared/services/retrieval/app_service.py index b91e31f0c..93f6d6d03 100644 --- a/packages/shared-python/shared/services/retrieval/app_service.py +++ b/packages/shared-python/shared/services/retrieval/app_service.py @@ -430,6 +430,15 @@ async def _to_public_response(response: dict[str, Any]) -> dict[str, Any]: 'router_used': response.get('router_used'), 'results': [], } + + # Forward agentic evidence fields when present + if response.get('evidence_text') is not None: + public_response['evidence_text'] = response['evidence_text'] + if response.get('answer_text') is not None: + public_response['answer_text'] = response['answer_text'] + if response.get('referenced_chunks') is not None: + public_response['referenced_chunks'] = response['referenced_chunks'] + public_results: list[dict[str, Any]] = [] for row in response.get('results', []): artifact_ref = row.get('file_path') @@ -450,7 +459,10 @@ async def _to_public_response(response: dict[str, Any]) -> dict[str, Any]: public_row['asset_url'] = asset_url elif field in row: public_row[field] = row[field] - public_row['source'] = _to_public_source(row) + if 'source' in row: + public_row['source'] = row['source'] + else: + public_row['source'] = _to_public_source(row) public_results.append(public_row) public_response['results'] = public_results @@ -496,6 +508,52 @@ def _normalize_row_scores( row[target_field] = round((raw_score - min_score) / denominator, 6) +def _importance_multiplier( + rows: list[dict[str, Any]], + *, + raw_field: str = 'importance_raw_score', + low: float = 0.1, + high: float = 2.0, +) -> None: + """Apply adaptive sigmoid-based importance boost to agent/discovery scores. + + Uses median of ``raw_field`` as center and IQR as spread so the curve + adapts to any KB size without hard-coded thresholds. When all values + are identical (IQR ≈ 0) the multiplier is 1.0 (neutral). + + Output range ``[low, high]`` — default [0.1, 2.0] — is the only + configured constant: max 2× boost, min 10%. The function modifies + ``agent_score`` and ``discovery_score`` **in place**. + """ + import math + + if not rows: + return + + values = sorted(float(r.get(raw_field, 0.0) or 0.0) for r in rows) + n = len(values) + median = values[n // 2] if n % 2 else (values[n // 2 - 1] + values[n // 2]) / 2 + q1 = values[n // 4] if n >= 4 else values[0] + q3 = values[3 * n // 4] if n >= 4 else values[-1] + iqr = q3 - q1 + + for row in rows: + raw = float(row.get(raw_field, 0.0) or 0.0) + if iqr <= 1e-9: + mult = 1.0 + else: + z = (raw - median) / iqr + s = 1.0 / (1.0 + math.exp(-z)) + mult = low + (high - low) * s + row['importance_multiplier'] = round(mult, 4) + row['agent_score'] = round( + float(row.get('agent_score', 0.0) or 0.0) * mult, 6, + ) + row['discovery_score'] = round( + float(row.get('discovery_score', 0.0) or 0.0) * mult, 6, + ) + + async def _load_chunk_importance_scores( db: AsyncSession, *, @@ -548,8 +606,7 @@ def _rank_candidates_by_path( candidate = dict(row) candidate['discovery_score'] = float(row.get('discovery_score', 0.0) or 0.0) candidate['agent_score'] = 0.0 - candidate['importance_raw_score'] = float(row.get('importance_raw_score', 0.0) or 0.0) - candidate['importance_norm_score'] = float(row.get('importance_norm_score', 0.0) or 0.0) + candidate.setdefault('hydrate_mode', 'chunks') merged[key] = candidate insertion_order[key] = counter counter += 1 @@ -563,22 +620,12 @@ def _rank_candidates_by_path( candidate = dict(row) candidate['discovery_score'] = float(row.get('discovery_score', 0.0) or 0.0) candidate['agent_score'] = routed_agent_score - candidate['importance_raw_score'] = float(row.get('importance_raw_score', 0.0) or 0.0) - candidate['importance_norm_score'] = float(row.get('importance_norm_score', 0.0) or 0.0) merged[key] = candidate insertion_order[key] = counter counter += 1 continue candidate = merged[key] candidate['agent_score'] = max(float(candidate.get('agent_score', 0.0) or 0.0), routed_agent_score) - candidate['importance_raw_score'] = max( - float(candidate.get('importance_raw_score', 0.0) or 0.0), - float(row.get('importance_raw_score', 0.0) or 0.0), - ) - candidate['importance_norm_score'] = max( - float(candidate.get('importance_norm_score', 0.0) or 0.0), - float(row.get('importance_norm_score', 0.0) or 0.0), - ) if not candidate.get('source_chunk_path') and row.get('source_chunk_path'): candidate['source_chunk_path'] = row.get('source_chunk_path') if not candidate.get('section_path') and row.get('section_path'): @@ -587,18 +634,16 @@ def _rank_candidates_by_path( # ── Dual-priority ranking ──────────────────────────────────────────── # When the agent produced results (routed_rows non-empty), rows with # agent_score=0 are demoted to a fallback pool. Primary sort is by - # agent_score (LLM confidence, 0-1, cross-round comparable), with - # discovery_score as tiebreaker only. This avoids the old - # `max(agent, discovery)` which mixed incompatible score sources. + # agent_score (includes importance boost from _importance_multiplier), + # with discovery_score as tiebreaker. has_agent_results = len(routed_rows) > 0 primary_rows: list[dict[str, Any]] = [] fallback_rows: list[dict[str, Any]] = [] for key, row in merged.items(): - discovery_score = float(row.get('discovery_score', 0.0) or 0.0) agent_score = float(row.get('agent_score', 0.0) or 0.0) - row['dual_hit_flag'] = 1 if discovery_score > 0.0 and agent_score > 0.0 else 0 + discovery_score = float(row.get('discovery_score', 0.0) or 0.0) row['evidence_score'] = round(agent_score if has_agent_results else max(discovery_score, agent_score), 6) row['score'] = row['evidence_score'] row['_candidate_order'] = insertion_order[key] @@ -612,8 +657,6 @@ def _sort_key(row): return ( float(row.get('agent_score', 0.0) or 0.0), float(row.get('discovery_score', 0.0) or 0.0), - int(row.get('dual_hit_flag', 0) or 0), - float(row.get('importance_norm_score', 0.0) or 0.0), -int(row.get('_candidate_order', 0) or 0), ) @@ -716,9 +759,14 @@ async def _hydrate_paths_to_rows( path_selections: list[dict[str, Any]], user_id: str, namespace: str, + document_id: str | None = None, ) -> list[dict[str, Any]]: """Load full chunk rows by section_path or source_chunk_path. + When *document_id* is provided the query is scoped to that single + document, preventing cross-document collisions on generic paths + such as ``Root``. + Supports hydrate_mode branching: - 'chunks' (default): all chunk types under the section subtree - 'outline': synthetic row from section metadata, no real chunks @@ -770,6 +818,8 @@ async def _hydrate_paths_to_rows( .where(Document.status == 'active') .where(or_(*outline_section_filters)) ) + if document_id: + outline_stmt = outline_stmt.where(Document.document_id == document_id) outline_result = await db.execute(outline_stmt) for document, section in outline_result.all(): agent_score = confidence_by_path.get(section.section_path, 0.0) @@ -820,6 +870,8 @@ async def _hydrate_paths_to_rows( ) ) ) + if document_id: + stmt = stmt.where(Document.document_id == document_id) result = await db.execute(stmt) # Build a map of path → allowed chunk_types based on hydrate_mode @@ -1048,7 +1100,7 @@ async def run_retrieval_query( llm_fn = _create_llm() agent = RetrievalAgent() - ranked_rows, router_used = await agent.run( + agentic_result = await agent.run( db, user_id=user_id, namespace=namespace, @@ -1063,6 +1115,83 @@ async def run_retrieval_query( channels=channels, channel_weights=channel_weights, ) + router_used = agentic_result.router_used + + # Generate asset URLs for media chunks in referenced_chunks + enriched_refs: list[dict[str, Any]] = [] + for ref in agentic_result.referenced_chunks: + enriched = dict(ref) + chunk_type = _normalize_chunk_type(ref.get('chunk_type')) + artifact_ref = ref.get('file_path', '') + job_id = ref.get('job_id', '') + if chunk_type in _MEDIA_CHUNK_TYPES and job_id and _is_client_result_artifact_ref(artifact_ref): + try: + asset_url = await generate_retrieval_asset_url( + job_id=str(job_id), artifact_ref=str(artifact_ref), + ) + if asset_url: + enriched['asset_url'] = asset_url + except Exception as e: + logger.warning(f'Failed to generate agentic asset URL (ignored): {e}') + enriched_refs.append(enriched) + + # Build backward-compatible results[] from referenced_chunks + # (minimal: chunk_id + document_id + chunk_type + section_path) + results = [ + { + 'chunk_id': ref.get('chunk_id'), + 'document_id': ref.get('document_id'), + 'chunk_type': ref.get('chunk_type'), + 'source': { + 'document_id': ref.get('document_id'), + 'section_path': ref.get('section_path'), + }, + } + for ref in enriched_refs + ] + + response = { + "namespace": namespace, + "query": query, + "router_used": router_used, + "results": results, + "evidence_text": agentic_result.evidence_text, + "answer_text": agentic_result.answer_text, + "referenced_chunks": enriched_refs, + } + + if cache_version is not None: + try: + await set_cached_retrieval_query_result( + user_id=user_id, namespace=namespace, version=cache_version, + query=query, top_k=top_k, + exclude_document_ids=exclude_document_ids, + exclude_sections=exclude_sections, + response=response, **cache_extra, + ) + except Exception as e: + logger.warning(f"Failed to write retrieval cache (ignored): {e}") + + try: + schedule_retrieval_hit_stats_update( + user_id=user_id, namespace=namespace, + results=agentic_result.referenced_chunks, + ) + except Exception as e: + logger.warning(f"Failed to trigger retrieval hit stats update (ignored): {e}") + + elapsed_total = round((time.monotonic() - t_start) * 1000) + logger.info( + f'\n{"█" * 70}\n' + f' ✅ AGENTIC RETRIEVAL COMPLETE: ' + f'{len(enriched_refs)} chunks | ' + f'evidence={len(agentic_result.evidence_text)} chars | ' + f'answer={len(agentic_result.answer_text)} chars | ' + f'router={router_used} | {elapsed_total}ms\n' + f'{"█" * 70}' + ) + + return await _to_public_response(response) else: # ── LEGACY path (existing code, unchanged) ── @@ -1208,17 +1337,6 @@ async def run_retrieval_query( chunk_importance_scores = {} for row in combined_rows: row['importance_raw_score'] = float(chunk_importance_scores.get(str(row.get('chunk_id') or ''), 0.0) or 0.0) - positive_importance = [row['importance_raw_score'] for row in combined_rows if row['importance_raw_score'] > 0.0] - if positive_importance: - _normalize_row_scores( - combined_rows, - source_field='importance_raw_score', - target_field='importance_norm_score', - default=0.5, - ) - else: - for row in combined_rows: - row['importance_norm_score'] = 0.0 ranked_rows = _rank_candidates_by_path(fused_rows, agent_rows, top_k) if ranked_rows: @@ -1227,8 +1345,6 @@ async def run_retrieval_query( logger.info( ' ' f'[{i}] evidence={row.get("evidence_score", 0.0):.4f} ' - f'dual_hit={row.get("dual_hit_flag", 0)} ' - f'importance={row.get("importance_norm_score", 0.0):.4f} ' f'discovery={row.get("discovery_score", 0.0):.4f} ' f'agent={row.get("agent_score", 0.0):.4f} ' f'path={_get_row_path(row)}' diff --git a/packages/shared-python/shared/services/retrieval/llm_adapter.py b/packages/shared-python/shared/services/retrieval/llm_adapter.py index 4a8b6e70b..8d9a11c22 100644 --- a/packages/shared-python/shared/services/retrieval/llm_adapter.py +++ b/packages/shared-python/shared/services/retrieval/llm_adapter.py @@ -6,13 +6,15 @@ from __future__ import annotations import asyncio -from typing import Any, Callable, Coroutine +from typing import Any, Callable, Coroutine, Union, Sequence, cast from loguru import logger from shared.core.config import settings -LLMFn = Callable[[str], Coroutine[Any, Any, str]] +# LLMFn accepts either a plain string or a list of ChatCompletionMessageParam +LLMFnInput = Union[str, Sequence[dict[str, Any]]] +LLMFn = Callable[[LLMFnInput], Coroutine[Any, Any, str]] _RETRIEVAL_LLM_TEMPERATURE = 0.1 _RETRIEVAL_LLM_MAX_TOKENS = 2048 @@ -63,14 +65,14 @@ def create_retrieval_llm_fn( effective_model = model or _resolve_default_model() - async def llm_fn(prompt: str) -> str: + async def llm_fn(prompt: LLMFnInput) -> str: from shared.utils.OpenAICompatibleClientSync import get_openai_client client = get_openai_client(model=effective_model) try: result = await asyncio.to_thread( client.chat_completion, - prompt, + cast(Any, prompt), model=effective_model, temperature=temperature, max_tokens=max_tokens,