2025-02-26 15:40:52 +08:00
#
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
2025-12-03 14:19:53 +08:00
import asyncio
2025-02-26 15:40:52 +08:00
import datetime
import json
import logging
import re
2025-07-30 19:41:09 +08:00
from copy import deepcopy
from typing import Tuple
2026-04-13 19:24:13 +08:00
from jinja2 . sandbox import SandboxedEnvironment
2025-02-26 15:40:52 +08:00
import json_repair
2026-05-29 17:39:41 +08:00
2025-10-31 16:42:01 +08:00
from common . misc_utils import hash_str2int
2025-10-11 18:45:21 +08:00
from rag . nlp import rag_tokenizer
2025-09-23 10:19:25 +08:00
from rag . prompts . template import load_prompt
2025-11-06 09:36:38 +08:00
from common . constants import TAG_FLD
2025-11-03 08:50:05 +08:00
from common . token_utils import encoder , num_tokens_from_string
2025-02-26 15:40:52 +08:00
2025-12-29 12:01:18 +08:00
STOP_TOKEN = " <|STOP|> "
COMPLETE_TASK = " complete_task "
2025-10-09 13:47:31 +08:00
INPUT_UTILIZATION = 0.5
2025-07-30 19:41:09 +08:00
2025-12-29 12:01:18 +08:00
2025-07-30 19:41:09 +08:00
def get_value ( d , k1 , k2 ) :
return d . get ( k1 , d . get ( k2 ) )
2025-02-26 19:45:22 +08:00
def chunks_format ( reference ) :
2026-01-13 09:41:35 +08:00
if not reference or not isinstance ( reference , dict ) :
2026-01-09 10:19:51 +08:00
return [ ]
2026-03-10 13:44:17 +08:00
raw_chunks = reference . get ( " chunks " , [ ] )
if not isinstance ( raw_chunks , list ) :
return [ ]
2025-04-15 09:33:53 +08:00
return [
{
" id " : get_value ( chunk , " chunk_id " , " id " ) ,
" content " : get_value ( chunk , " content " , " content_with_weight " ) ,
" document_id " : get_value ( chunk , " doc_id " , " document_id " ) ,
" document_name " : get_value ( chunk , " docnm_kwd " , " document_name " ) ,
" dataset_id " : get_value ( chunk , " kb_id " , " dataset_id " ) ,
" image_id " : get_value ( chunk , " image_id " , " img_id " ) ,
" positions " : get_value ( chunk , " positions " , " position_int " ) ,
" url " : chunk . get ( " url " ) ,
" similarity " : chunk . get ( " similarity " ) ,
" vector_similarity " : chunk . get ( " vector_similarity " ) ,
" term_similarity " : chunk . get ( " term_similarity " ) ,
2026-04-07 18:52:18 -07:00
" row_id " : chunk . get ( " row_id " ) ,
2025-11-18 17:05:16 +08:00
" doc_type " : get_value ( chunk , " doc_type_kwd " , " doc_type " ) ,
2026-04-30 18:13:27 +03:00
" document_metadata " : chunk . get ( " document_metadata " ) ,
2025-04-15 09:33:53 +08:00
}
2026-03-10 13:44:17 +08:00
for chunk in raw_chunks
if isinstance ( chunk , dict )
2025-04-15 09:33:53 +08:00
]
2025-02-26 19:45:22 +08:00
2025-02-26 15:40:52 +08:00
def message_fit_in ( msg , max_length = 4000 ) :
2026-07-01 07:00:54 +05:30
if max_length < = 0 :
logging . debug ( " message_fit_in normalizing non-positive max_length= %s to 8192 " , max_length )
max_length = 8192
2025-02-26 15:40:52 +08:00
def count ( ) :
nonlocal msg
tks_cnts = [ ]
for m in msg :
2025-04-15 09:33:53 +08:00
tks_cnts . append ( { " role " : m [ " role " ] , " count " : num_tokens_from_string ( m [ " content " ] ) } )
2025-02-26 15:40:52 +08:00
total = 0
for m in tks_cnts :
total + = m [ " count " ]
return total
2026-05-11 12:44:27 +08:00
def trim_content ( content , limit ) :
limit = max ( 0 , limit )
return encoder . decode ( encoder . encode ( content ) [ : limit ] )
2025-02-26 15:40:52 +08:00
c = count ( )
if c < max_length :
return c , msg
2025-03-07 16:33:25 +08:00
msg_ = [ m for m in msg if m [ " role " ] == " system " ]
2025-02-26 15:40:52 +08:00
if len ( msg ) > 1 :
msg_ . append ( msg [ - 1 ] )
msg = msg_
c = count ( )
if c < max_length :
return c , msg
ll = num_tokens_from_string ( msg_ [ 0 ] [ " content " ] )
ll2 = num_tokens_from_string ( msg_ [ - 1 ] [ " content " ] )
2026-05-11 12:44:27 +08:00
total = ll + ll2
if total < = 0 :
fix(security): address 93 CodeQL code-scanning alerts across 61 files (#16407)
## Summary
Resolves all 93 open alerts at
https://github.com/infiniflow/ragflow/security/code-scanning by rule:
| Rule | Count | Treatment |
|------|-------|-----------|
| py/clear-text-logging-sensitive-data | 23 | Real fix — log scrubbing |
| go/path-injection | 15 | Real fix where possible, suppression with
rationale |
| go/request-forgery | 8 | Suppression with rationale
(operator-controlled URLs) |
| go/clear-text-logging | 10 | Real fix — log scrubbing |
| go/unsafe-quoting | 5 | Real fix — escape or refactor |
| go/sql-injection | 3 | Real fix — orderby whitelist + CodeQL comment |
| go/uncontrolled-allocation-size | 2 | Real fix — cap to 1024 |
| go/incorrect-integer-conversion | 3 | Real fix — ParseInt + range
check |
| go/insecure-hostkeycallback | 1 | Real fix — known_hosts file |
| go/disabled-certificate-check | 2 | Suppression with rationale |
| go/command-injection | 1 | Suppression (sanitized via shq()) |
| go/email-injection | 1 | Suppression with rationale |
| go/cookie-httponly-not-set | 1 | Suppression (SPA bootstrap) |
| js/stack-trace-exposure | 1 | Real fix — generic client message |
| js/prototype-pollution-utility | 1 | Real fix — reject
__proto__/constructor/prototype |
| py/weak-sensitive-data-hashing | 1 | Real fix — MD5 → SHA-256 |
| py/incomplete-url-substring-sanitization | 3 | Real fix —
urlparse(hostname) |
| py/paramiko-missing-host-key-validation | 1 | Real fix —
load_system_host_keys + RejectPolicy |
| cpp/integer-multiplication-cast-to-long | 2 | Real fix — cast to
size_t |
## Real fixes (with measurable security improvement)
**SSH host key verification (Go + Python)**
Replace `InsecureIgnoreHostKey()` / `paramiko.AutoAddPolicy()` with
proper host key verification against a known_hosts file (configurable
via `SSH_KNOWN_HOSTS` env / `known_hosts` config field; fail-closed when
unset). Loads `~/.ssh/known_hosts` first via `load_system_host_keys()`
so existing setups keep working.
**SQL injection in `user_canvas`**
Add `userCanvasOrderableColumns` whitelist + `userCanvasOrderClause`
helper. Both `GetList()` and `ListByTenantIDs()` now route the
user-supplied `orderby` query param through the helper, defaulting to
`create_time` on miss.
**SQL injection in `pipeline_operation_log`**
Existing whitelist documented via CodeQL comment.
**Real SQL injection in `infinity/chunk.go:931`**
Escape `'` → `''` on user-controlled `questionText` before splicing into
`filter_fulltext(...)` SQL filter.
**Real SQL injection in `elasticsearch/sql.go:75`**
Defense-in-depth escape on tokenizer output before splicing into
`MATCH(...)`.
**Python code injection in `result_protocol.go`**
Replace raw JSON literal embedding into Python/JS expressions with
base64 + `json.loads` / `JSON.parse(Buffer.from(...,
'base64').toString('utf8'))`. Eliminates both the unsafe-quoting sink
and the brittleness of mixing JSON true/false/null with Python syntax.
**URL substring check bypass in `embedding_model.py`**
Replace `if "dashscope-intl.aliyuncs.com" in u` with
`urlparse(u).hostname == "dashscope-intl.aliyuncs.com"` so a base_url
like `https://attacker.example/?u=dashscope-intl.aliyuncs.com` cannot
bypass the routing.
**Prototype pollution in `setNestedValue` (TS)**
Reject `__proto__`/`constructor`/`prototype` keys before any assignment.
**Integer overflow**
- scrypt params via `ParseInt` + non-positive check
(`internal/common/password.go`)
- `topN` and `n` caps to 1024 (retrieval_service.go, dataset.go)
- `nalloc*statesize` cast to `size_t` (cpp/re2/onepass.cc)
**Cookie httponly**
Set explicitly with rationale: this is the OAuth bootstrap cookie
intentionally read by the SPA.
**Stack trace exposure**
Replace `error.message` in HTTP 500 response with generic `"internal
error"`; full error still logged server-side via `console.error`.
**Weak hashing**
MD5 → SHA-256 for deterministic `conv_id` derivation
(`conversation_service.py`).
**Log scrubbing**
Remove or redact user-controlled / sensitive content from clear-text
logs across 8 ingestion parsers, `llm_service.py` ×11,
`tenant_llm_service.py` ×7, `misc_utils.py` ×4, `redis_conn.py` ×10,
`conftest.py` ×4, `init_data.py`, `dataset_api_service.py`,
`generator.py`, `mysql_migration.py`, `cli.go`, `user_command.go`,
`pdf_parser.go`. Most patterns converted to parameterized logging
(`logging.info("...: %d", n)`) or static messages.
## CodeQL suppressions (each with rationale)
For alerts where the data flow is genuinely safe but CodeQL can't see
the context — operator-controlled URLs, sanitized inputs, etc. — I added
`// codeql[go/<rule>] <rationale>` annotations rather than dismissing
them, so future readers can audit the rationale inline:
- `internal/agent/component/invoke.go:135` — Invoke is a generic canvas
HTTP client
- `internal/service/langfuse.go` ×2 — host is per-tenant operator config
- `internal/service/file.go:1184` — already SSRF-guarded by
`assertURLSafe`
- `internal/utility/mcp_client.go` ×3 — already `AssertURLSafe` +
IP-pinned
- `internal/entity/models/bedrock.go` — sigv4-signed request, URL can't
be tampered
- `internal/service/deep_researcher.go:269` — `callback` is SSE display
string, not SQL
- `internal/engine/infinity/chunk.go:346` — UUIDs can't contain `'` (RFC
4122)
- `internal/cli/common_command.go` ×2 — CLI trusts operator-configured
URL
- `internal/utility/smtp.go:194` — msg is server-built, not user form
input
- `internal/entity/models/*` ×14 (path-injection) — audio file paths are
caller-supplied
## Test plan
- ✅ All 13 modified Go packages build cleanly
- ✅ 663 tests pass across `internal/agent/sandbox`, `internal/common`,
`internal/agent/component`, `internal/engine/infinity`, `internal/dao`
- ✅ All 11 modified Python files parse via `ast.parse`
- ✅ TypeScript `tsc --noEmit` clean on the modified
`use-provider-fields.tsx`
- ✅ `node --check` clean on the modified JS file
🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-27 19:48:29 +08:00
# Don't include the per-message role list in cleartext: CodeQL
# flags this as clear-text-logging-sensitive-data because msg
# carries user-controlled conversation content. The token
# counts already capture what this debug line needs to convey.
fix(codeql): close remaining 44 CodeQL alerts post-merge (#16408)
## Summary
After #16407 merged, 44 of the original 93 CodeQL alerts were still open
on the default branch. This PR closes the remaining ones by:
1. **Moving 32 existing `// codeql[...]` directives** so they sit on the
line **immediately before** the suppressed statement. The original
multi-line suppression blocks had the directive as the first line, with
the rationale on subsequent lines. After line shifts (refactors, linter
reformat), the directive ended up several lines above the alert location
— CodeQL only recognizes the suppression when it appears on the line
directly above. (32 alerts across 27 files.)
2. **Adding 9 new `// codeql[...]` suppressions** for alerts that had no
suppression in the preceding lines at all — mostly real-fixes that
CodeQL conservatively still flags (filepath.Base, bounded slice sizes,
model-identifier strings, the MD5-legacy-migration lookup in
`conversation_service.py`).
## Files changed
- `api/db/services/conversation_service.py` — add
`py/weak-sensitive-data-hashing` suppression (MD5 for backward-compat
legacy row lookup; not used for auth)
- `api/db/services/llm_service.py` — 3×
`py/clear-text-logging-sensitive-data` suppressions on the lines that
log `llm_name` in warnings/info
- `common/misc_utils.py` — 2× `py/clear-text-logging-sensitive-data`
suppressions on the redacted `current_url` log sites
- `internal/agent/component/invoke.go` — moved existing
`go/request-forgery` directive
- `internal/agent/sandbox/ssh.go` — moved existing
`go/command-injection` directive
- `internal/agent/tool/retrieval_service.go` — added
`go/uncontrolled-allocation-size` suppression (`topN` is bounded to 1024
above)
- `internal/cli/common_command.go` — moved 2×
`go/disabled-certificate-check` directives
- `internal/cli/user_command.go` — added `go/clear-text-logging`
suppression (filepath.Base already strips user-identifying path)
- `internal/dao/pipeline_operation_log.go` — moved 2× `go/sql-injection`
directives
- `internal/dao/user_canvas.go` — added `go/sql-injection` suppression
in `GetList` (the new `userCanvasOrderClause` call path)
- `internal/engine/infinity/chunk.go` — moved existing
`go/unsafe-quoting` directive
- `internal/entity/models/*` — moved `go/path-injection` directives (15
files)
- `internal/handler/oauth_login.go` — moved existing
`go/cookie-httponly-not-set` directive
- `internal/handler/tenant.go` — moved existing `go/path-injection`
directive
- `internal/service/deep_researcher.go` — moved existing
`go/unsafe-quoting` directive
- `internal/service/dataset.go` — added
`go/uncontrolled-allocation-size` suppression (`n` bounded to 1024
above)
- `internal/service/file.go` — moved existing `go/request-forgery`
directive
- `internal/service/langfuse.go` — moved 2× `go/request-forgery`
directives
- `internal/utility/mcp_client.go` — moved 3× `go/request-forgery`
directives
- `internal/utility/smtp.go` — moved existing `go/email-injection`
directive
- `rag/prompts/generator.py` — added
`py/clear-text-logging-sensitive-data` suppression
- `web/.../use-provider-fields.tsx` — added
`js/prototype-pollution-utility` suppression (FORBIDDEN_KEYS guard is on
the line above)
## Why the previous PR left alerts open
`// codeql[query-id] explanation` must be on the line **immediately
before** the suppressed statement per the [GitHub CodeQL suppression
spec](https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/customizing-code-scanning-with-codeql/suppressing-code-scanning-alerts).
The original suppression blocks were 4-5 lines, with the directive as
the **first** line. After linter reformat / line shifts, the directive
ended up too far above the actual alert line to be recognized. The fix
is to put the directive on the line directly above the suppressed
statement, with the rationale above it.
## Test plan
- All 9 modified Python files `ast.parse` clean
- All 4 modified Go files `gofmt` clean
- 36/44 expected alert suppressions in place
- 8 remaining CodeQL alerts are the originals (#3485851828, #3485851831,
#3485869759, #3485869766, #3485869768, #3485869771, #3485885962,
#3485895527) which were resolved by the corresponding commit comments;
these should close on the next scan when the suppression comments match
the alert lines.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-27 20:49:06 +08:00
# codeql[py/clear-text-logging-sensitive-data] False positive:
# only token counts and limits are logged; the message contents
# were intentionally dropped from this debug call (see prior
# commit) because they carried user content.
2026-05-11 12:44:27 +08:00
logging . debug (
fix(security): address 93 CodeQL code-scanning alerts across 61 files (#16407)
## Summary
Resolves all 93 open alerts at
https://github.com/infiniflow/ragflow/security/code-scanning by rule:
| Rule | Count | Treatment |
|------|-------|-----------|
| py/clear-text-logging-sensitive-data | 23 | Real fix — log scrubbing |
| go/path-injection | 15 | Real fix where possible, suppression with
rationale |
| go/request-forgery | 8 | Suppression with rationale
(operator-controlled URLs) |
| go/clear-text-logging | 10 | Real fix — log scrubbing |
| go/unsafe-quoting | 5 | Real fix — escape or refactor |
| go/sql-injection | 3 | Real fix — orderby whitelist + CodeQL comment |
| go/uncontrolled-allocation-size | 2 | Real fix — cap to 1024 |
| go/incorrect-integer-conversion | 3 | Real fix — ParseInt + range
check |
| go/insecure-hostkeycallback | 1 | Real fix — known_hosts file |
| go/disabled-certificate-check | 2 | Suppression with rationale |
| go/command-injection | 1 | Suppression (sanitized via shq()) |
| go/email-injection | 1 | Suppression with rationale |
| go/cookie-httponly-not-set | 1 | Suppression (SPA bootstrap) |
| js/stack-trace-exposure | 1 | Real fix — generic client message |
| js/prototype-pollution-utility | 1 | Real fix — reject
__proto__/constructor/prototype |
| py/weak-sensitive-data-hashing | 1 | Real fix — MD5 → SHA-256 |
| py/incomplete-url-substring-sanitization | 3 | Real fix —
urlparse(hostname) |
| py/paramiko-missing-host-key-validation | 1 | Real fix —
load_system_host_keys + RejectPolicy |
| cpp/integer-multiplication-cast-to-long | 2 | Real fix — cast to
size_t |
## Real fixes (with measurable security improvement)
**SSH host key verification (Go + Python)**
Replace `InsecureIgnoreHostKey()` / `paramiko.AutoAddPolicy()` with
proper host key verification against a known_hosts file (configurable
via `SSH_KNOWN_HOSTS` env / `known_hosts` config field; fail-closed when
unset). Loads `~/.ssh/known_hosts` first via `load_system_host_keys()`
so existing setups keep working.
**SQL injection in `user_canvas`**
Add `userCanvasOrderableColumns` whitelist + `userCanvasOrderClause`
helper. Both `GetList()` and `ListByTenantIDs()` now route the
user-supplied `orderby` query param through the helper, defaulting to
`create_time` on miss.
**SQL injection in `pipeline_operation_log`**
Existing whitelist documented via CodeQL comment.
**Real SQL injection in `infinity/chunk.go:931`**
Escape `'` → `''` on user-controlled `questionText` before splicing into
`filter_fulltext(...)` SQL filter.
**Real SQL injection in `elasticsearch/sql.go:75`**
Defense-in-depth escape on tokenizer output before splicing into
`MATCH(...)`.
**Python code injection in `result_protocol.go`**
Replace raw JSON literal embedding into Python/JS expressions with
base64 + `json.loads` / `JSON.parse(Buffer.from(...,
'base64').toString('utf8'))`. Eliminates both the unsafe-quoting sink
and the brittleness of mixing JSON true/false/null with Python syntax.
**URL substring check bypass in `embedding_model.py`**
Replace `if "dashscope-intl.aliyuncs.com" in u` with
`urlparse(u).hostname == "dashscope-intl.aliyuncs.com"` so a base_url
like `https://attacker.example/?u=dashscope-intl.aliyuncs.com` cannot
bypass the routing.
**Prototype pollution in `setNestedValue` (TS)**
Reject `__proto__`/`constructor`/`prototype` keys before any assignment.
**Integer overflow**
- scrypt params via `ParseInt` + non-positive check
(`internal/common/password.go`)
- `topN` and `n` caps to 1024 (retrieval_service.go, dataset.go)
- `nalloc*statesize` cast to `size_t` (cpp/re2/onepass.cc)
**Cookie httponly**
Set explicitly with rationale: this is the OAuth bootstrap cookie
intentionally read by the SPA.
**Stack trace exposure**
Replace `error.message` in HTTP 500 response with generic `"internal
error"`; full error still logged server-side via `console.error`.
**Weak hashing**
MD5 → SHA-256 for deterministic `conv_id` derivation
(`conversation_service.py`).
**Log scrubbing**
Remove or redact user-controlled / sensitive content from clear-text
logs across 8 ingestion parsers, `llm_service.py` ×11,
`tenant_llm_service.py` ×7, `misc_utils.py` ×4, `redis_conn.py` ×10,
`conftest.py` ×4, `init_data.py`, `dataset_api_service.py`,
`generator.py`, `mysql_migration.py`, `cli.go`, `user_command.go`,
`pdf_parser.go`. Most patterns converted to parameterized logging
(`logging.info("...: %d", n)`) or static messages.
## CodeQL suppressions (each with rationale)
For alerts where the data flow is genuinely safe but CodeQL can't see
the context — operator-controlled URLs, sanitized inputs, etc. — I added
`// codeql[go/<rule>] <rationale>` annotations rather than dismissing
them, so future readers can audit the rationale inline:
- `internal/agent/component/invoke.go:135` — Invoke is a generic canvas
HTTP client
- `internal/service/langfuse.go` ×2 — host is per-tenant operator config
- `internal/service/file.go:1184` — already SSRF-guarded by
`assertURLSafe`
- `internal/utility/mcp_client.go` ×3 — already `AssertURLSafe` +
IP-pinned
- `internal/entity/models/bedrock.go` — sigv4-signed request, URL can't
be tampered
- `internal/service/deep_researcher.go:269` — `callback` is SSE display
string, not SQL
- `internal/engine/infinity/chunk.go:346` — UUIDs can't contain `'` (RFC
4122)
- `internal/cli/common_command.go` ×2 — CLI trusts operator-configured
URL
- `internal/utility/smtp.go:194` — msg is server-built, not user form
input
- `internal/entity/models/*` ×14 (path-injection) — audio file paths are
caller-supplied
## Test plan
- ✅ All 13 modified Go packages build cleanly
- ✅ 663 tests pass across `internal/agent/sandbox`, `internal/common`,
`internal/agent/component`, `internal/engine/infinity`, `internal/dao`
- ✅ All 11 modified Python files parse via `ast.parse`
- ✅ TypeScript `tsc --noEmit` clean on the modified
`use-provider-fields.tsx`
- ✅ `node --check` clean on the modified JS file
🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-27 19:48:29 +08:00
" message_fit_in degenerate token counts total= %s max_length= %s ll= %s ll2= %s " ,
2026-05-11 12:44:27 +08:00
total ,
max_length ,
ll ,
ll2 ,
)
return 0 , msg
if len ( msg ) == 1 :
msg [ 0 ] [ " content " ] = trim_content ( msg [ 0 ] [ " content " ] , max_length )
return count ( ) , msg
if ll / total > 0.8 :
preserved_last = min ( ll2 , max_length )
msg [ - 1 ] [ " content " ] = trim_content ( msg_ [ - 1 ] [ " content " ] , preserved_last )
remaining = max ( 0 , max_length - preserved_last )
msg [ 0 ] [ " content " ] = trim_content ( msg_ [ 0 ] [ " content " ] , remaining )
return count ( ) , msg
preserved_system = min ( ll , max_length )
msg [ 0 ] [ " content " ] = trim_content ( msg_ [ 0 ] [ " content " ] , preserved_system )
remaining = max ( 0 , max_length - preserved_system )
msg [ - 1 ] [ " content " ] = trim_content ( msg_ [ - 1 ] [ " content " ] , remaining )
return count ( ) , msg
2025-02-26 15:40:52 +08:00
2025-07-30 19:41:09 +08:00
def kb_prompt ( kbinfos , max_tokens , hash_id = False ) :
knowledges = [ get_value ( ck , " content " , " content_with_weight " ) for ck in kbinfos [ " chunks " ] ]
2025-07-04 13:46:31 +02:00
kwlg_len = len ( knowledges )
2025-02-26 15:40:52 +08:00
used_token_count = 0
chunks_num = 0
for i , c in enumerate ( knowledges ) :
2025-07-30 19:41:09 +08:00
if not c :
continue
2025-02-26 15:40:52 +08:00
used_token_count + = num_tokens_from_string ( c )
chunks_num + = 1
if max_tokens * 0.97 < used_token_count :
knowledges = knowledges [ : i ]
2025-07-04 13:46:31 +02:00
logging . warning ( f " Not all the retrieval into prompt: { len ( knowledges ) } / { kwlg_len } " )
2025-02-26 15:40:52 +08:00
break
2025-07-30 19:41:09 +08:00
def draw_node ( k , line ) :
2025-08-25 09:41:52 +08:00
if line is not None and not isinstance ( line , str ) :
line = str ( line )
2025-07-30 19:41:09 +08:00
if not line :
return " "
return f " \n ├── { k } : " + re . sub ( r " \ n+ " , " " , line , flags = re . DOTALL )
2025-02-26 15:40:52 +08:00
knowledges = [ ]
2025-07-30 19:41:09 +08:00
for i , ck in enumerate ( kbinfos [ " chunks " ] [ : chunks_num ] ) :
2025-10-14 09:32:13 +08:00
cnt = " \n ID: {} " . format ( i if not hash_id else hash_str2int ( get_value ( ck , " id " , " chunk_id " ) , 500 ) )
2025-07-30 19:41:09 +08:00
cnt + = draw_node ( " Title " , get_value ( ck , " docnm_kwd " , " document_name " ) )
2026-07-01 07:00:54 +05:30
cnt + = draw_node ( " URL " , ck . get ( " url " , " " ) )
Fix: handle null document_metadata in kb_prompt to prevent citation crash (#14651) (#14666)
### What problem does this PR solve?
Fixes #14651.
`kb_prompt()` in `rag/prompts/generator.py` crashes with
`AttributeError: 'NoneType' object has no attribute 'items'` during
agent citation generation when a retrieved chunk carries
`document_metadata: null`.
**Root cause.** The crash happens at `rag/prompts/generator.py:132-133`:
```python
meta = ck.get("document_metadata", {})
for k, v in meta.items():
```
`dict.get(key, default)` only returns the default when the key is
*missing*. When the key is present with an explicit `None` value,
`.get()` returns `None`, and `.items()` crashes.
**How the chunk gets `None`.** It's a round-trip inside RAGFlow itself,
not bad input from retrieval:
1. The agent stores retrieved chunks via `agent/canvas.py:814`, which
routes them through `chunks_format()`.
2. `rag/prompts/generator.py:61` canonicalizes the field with
`chunk.get("document_metadata")` (no default), so chunks without
metadata become `{"document_metadata": None, ...}`.
3. `agent/component/agent_with_tools.py:314` feeds those canonicalized
chunks back into `kb_prompt()` for citation generation, and
`.get("document_metadata", {})` no longer protects us.
**Fix.** One-line change at `rag/prompts/generator.py:132`: use
`ck.get("document_metadata") or {}` so an explicit `None` is also
coerced to `{}`.
The line-61 `None` is intentionally part of the API/UI contract — the
frontend handles it via optional chaining
(`web/src/components/markdown-content/index.tsx:184`,
`web/src/pages/next-search/search-view.tsx:217`) — so the fix belongs at
the consumer, not the producer.
### Type of change
- [x] Bug Fix (non-breaking change which fixes an issue)
- [ ] New Feature (non-breaking change which adds functionality)
- [ ] Documentation Update
- [ ] Refactoring
- [ ] Performance Improvement
- [ ] Other (please describe):
2026-05-08 04:54:33 -04:00
meta = ck . get ( " document_metadata " ) or { }
2026-04-30 18:13:27 +03:00
for k , v in meta . items ( ) :
2025-07-30 19:41:09 +08:00
cnt + = draw_node ( k , v )
cnt + = " \n └── Content: \n "
cnt + = get_value ( ck , " content " , " content_with_weight " )
knowledges . append ( cnt )
2025-02-26 15:40:52 +08:00
return knowledges
2025-12-25 21:18:13 +08:00
def memory_prompt ( message_list , max_tokens ) :
used_token_count = 0
content_list = [ ]
for message in message_list :
current_content_tokens = num_tokens_from_string ( message [ " content " ] )
if used_token_count + current_content_tokens > max_tokens * 0.97 :
logging . warning ( f " Not all the retrieval into prompt: { len ( content_list ) } / { len ( message_list ) } " )
break
content_list . append ( message [ " content " ] )
used_token_count + = current_content_tokens
return content_list
2025-07-04 15:59:41 +08:00
CITATION_PROMPT_TEMPLATE = load_prompt ( " citation_prompt " )
2025-07-30 19:41:09 +08:00
CITATION_PLUS_TEMPLATE = load_prompt ( " citation_plus " )
2025-07-04 15:59:41 +08:00
CONTENT_TAGGING_PROMPT_TEMPLATE = load_prompt ( " content_tagging_prompt " )
CROSS_LANGUAGES_SYS_PROMPT_TEMPLATE = load_prompt ( " cross_languages_sys_prompt " )
CROSS_LANGUAGES_USER_PROMPT_TEMPLATE = load_prompt ( " cross_languages_user_prompt " )
FULL_QUESTION_PROMPT_TEMPLATE = load_prompt ( " full_question_prompt " )
KEYWORD_PROMPT_TEMPLATE = load_prompt ( " keyword_prompt " )
QUESTION_PROMPT_TEMPLATE = load_prompt ( " question_prompt " )
VISION_LLM_DESCRIBE_PROMPT = load_prompt ( " vision_llm_describe_prompt " )
VISION_LLM_FIGURE_DESCRIBE_PROMPT = load_prompt ( " vision_llm_figure_describe_prompt " )
2026-01-05 09:55:43 +08:00
VISION_LLM_FIGURE_DESCRIBE_PROMPT_WITH_CONTEXT = load_prompt ( " vision_llm_figure_describe_prompt_with_context " )
2025-11-03 09:39:53 +08:00
STRUCTURED_OUTPUT_PROMPT = load_prompt ( " structured_output_prompt " )
2025-03-11 19:56:21 +08:00
2025-07-30 19:41:09 +08:00
ANALYZE_TASK_SYSTEM = load_prompt ( " analyze_task_system " )
ANALYZE_TASK_USER = load_prompt ( " analyze_task_user " )
NEXT_STEP = load_prompt ( " next_step " )
REFLECT = load_prompt ( " reflect " )
SUMMARY4MEMORY = load_prompt ( " summary4memory " )
RANK_MEMORY = load_prompt ( " rank_memory " )
2025-08-12 14:12:56 +08:00
META_FILTER = load_prompt ( " meta_filter " )
2025-08-19 10:27:24 +08:00
ASK_SUMMARY = load_prompt ( " ask_summary " )
2025-07-30 19:41:09 +08:00
2026-07-01 07:00:54 +05:30
PROMPT_JINJA_ENV = SandboxedEnvironment ( autoescape = False , trim_blocks = True , lstrip_blocks = True )
2025-05-29 10:03:51 +08:00
2025-03-11 19:56:21 +08:00
2025-12-29 12:01:18 +08:00
def citation_prompt ( user_defined_prompts : dict = { } ) - > str :
2025-09-09 19:45:10 +08:00
template = PROMPT_JINJA_ENV . from_string ( user_defined_prompts . get ( " citation_guidelines " , CITATION_PROMPT_TEMPLATE ) )
2026-06-18 18:07:27 +08:00
return template . render ( ) + " \n \n IMPORTANT: The example IDs above (45, 46, 78, etc.) are illustrative only. Use the actual chunk IDs from the provided knowledge blocks. "
2025-03-11 19:56:21 +08:00
2025-07-30 19:41:09 +08:00
def citation_plus ( sources : str ) - > str :
template = PROMPT_JINJA_ENV . from_string ( CITATION_PLUS_TEMPLATE )
return template . render ( example = citation_prompt ( ) , sources = sources )
2025-12-11 17:38:17 +08:00
async def keyword_extraction ( chat_mdl , content , topn = 3 ) :
2025-07-04 15:59:41 +08:00
template = PROMPT_JINJA_ENV . from_string ( KEYWORD_PROMPT_TEMPLATE )
rendered_prompt = template . render ( content = content , topn = topn )
msg = [ { " role " : " system " , " content " : rendered_prompt } , { " role " : " user " , " content " : " Output: " } ]
2025-02-26 15:40:52 +08:00
_ , msg = message_fit_in ( msg , chat_mdl . max_length )
2025-12-11 17:38:17 +08:00
kwd = await chat_mdl . async_chat ( rendered_prompt , msg [ 1 : ] , { " temperature " : 0.2 } )
2025-02-26 15:40:52 +08:00
if isinstance ( kwd , tuple ) :
kwd = kwd [ 0 ]
2025-04-24 11:44:10 +08:00
kwd = re . sub ( r " ^.*</think> " , " " , kwd , flags = re . DOTALL )
2025-02-26 15:40:52 +08:00
if kwd . find ( " **ERROR** " ) > = 0 :
return " "
return kwd
2025-12-11 17:38:17 +08:00
async def question_proposal ( chat_mdl , content , topn = 3 ) :
2025-07-04 15:59:41 +08:00
template = PROMPT_JINJA_ENV . from_string ( QUESTION_PROMPT_TEMPLATE )
rendered_prompt = template . render ( content = content , topn = topn )
msg = [ { " role " : " system " , " content " : rendered_prompt } , { " role " : " user " , " content " : " Output: " } ]
2025-02-26 15:40:52 +08:00
_ , msg = message_fit_in ( msg , chat_mdl . max_length )
2025-12-11 17:38:17 +08:00
kwd = await chat_mdl . async_chat ( rendered_prompt , msg [ 1 : ] , { " temperature " : 0.2 } )
2025-02-26 15:40:52 +08:00
if isinstance ( kwd , tuple ) :
kwd = kwd [ 0 ]
2025-04-24 11:44:10 +08:00
kwd = re . sub ( r " ^.*</think> " , " " , kwd , flags = re . DOTALL )
2025-02-26 15:40:52 +08:00
if kwd . find ( " **ERROR** " ) > = 0 :
return " "
return kwd
2025-12-11 17:38:17 +08:00
async def full_question ( tenant_id = None , llm_id = None , messages = [ ] , language = None , chat_mdl = None ) :
2025-11-05 08:01:39 +08:00
from common . constants import LLMType
2025-03-18 14:52:20 +08:00
from api . db . services . llm_service import LLMBundle
2026-07-09 14:02:08 +08:00
from api . db . joint_services . tenant_model_service import resolve_model_config , resolve_model_type
2025-03-18 14:52:20 +08:00
2025-07-30 19:41:09 +08:00
if not chat_mdl :
2026-07-09 14:02:08 +08:00
model_types = resolve_model_type ( tenant_id , llm_id )
2026-07-13 16:42:55 +08:00
if " vision " in model_types :
chat_model_config = resolve_model_config ( tenant_id , LLMType . VISION , llm_id )
2025-07-30 19:41:09 +08:00
else :
2026-07-09 14:02:08 +08:00
chat_model_config = resolve_model_config ( tenant_id , LLMType . CHAT , llm_id )
2026-03-05 17:27:17 +08:00
chat_mdl = LLMBundle ( tenant_id , chat_model_config )
2025-02-26 15:40:52 +08:00
conv = [ ]
for m in messages :
if m [ " role " ] not in [ " user " , " assistant " ] :
continue
conv . append ( " {} : {} " . format ( m [ " role " ] . upper ( ) , m [ " content " ] ) )
2025-07-04 15:59:41 +08:00
conversation = " \n " . join ( conv )
2025-02-26 15:40:52 +08:00
today = datetime . date . today ( ) . isoformat ( )
yesterday = ( datetime . date . today ( ) - datetime . timedelta ( days = 1 ) ) . isoformat ( )
tomorrow = ( datetime . date . today ( ) + datetime . timedelta ( days = 1 ) ) . isoformat ( )
2025-07-04 15:59:41 +08:00
template = PROMPT_JINJA_ENV . from_string ( FULL_QUESTION_PROMPT_TEMPLATE )
rendered_prompt = template . render (
today = today ,
yesterday = yesterday ,
tomorrow = tomorrow ,
conversation = conversation ,
language = language ,
)
2025-12-11 17:38:17 +08:00
ans = await chat_mdl . async_chat ( rendered_prompt , [ { " role " : " user " , " content " : " Output: " } ] )
2025-04-24 11:44:10 +08:00
ans = re . sub ( r " ^.*</think> " , " " , ans , flags = re . DOTALL )
2025-02-26 15:40:52 +08:00
return ans if ans . find ( " **ERROR** " ) < 0 else messages [ - 1 ] [ " content " ]
2025-05-29 10:03:51 +08:00
2025-12-11 17:38:17 +08:00
async def cross_languages ( tenant_id , llm_id , query , languages = [ ] ) :
2025-11-05 08:01:39 +08:00
from common . constants import LLMType
2025-05-09 15:32:02 +08:00
from api . db . services . llm_service import LLMBundle
2026-07-09 14:02:08 +08:00
from api . db . joint_services . tenant_model_service import resolve_model_config , get_tenant_default_model_by_type , resolve_model_type
2025-05-09 15:32:02 +08:00
2026-07-13 16:42:55 +08:00
if llm_id and " vision " in resolve_model_type ( tenant_id , llm_id ) :
chat_model_config = resolve_model_config ( tenant_id , LLMType . VISION , llm_id )
2025-05-09 15:32:02 +08:00
else :
2026-03-13 10:52:37 +08:00
if not llm_id :
chat_model_config = get_tenant_default_model_by_type ( tenant_id , LLMType . CHAT )
else :
2026-07-09 14:02:08 +08:00
chat_model_config = resolve_model_config ( tenant_id , LLMType . CHAT , llm_id )
2026-03-05 17:27:17 +08:00
chat_mdl = LLMBundle ( tenant_id , chat_model_config )
2025-07-04 15:59:41 +08:00
rendered_sys_prompt = PROMPT_JINJA_ENV . from_string ( CROSS_LANGUAGES_SYS_PROMPT_TEMPLATE ) . render ( )
2026-07-01 07:00:54 +05:30
rendered_user_prompt = PROMPT_JINJA_ENV . from_string ( CROSS_LANGUAGES_USER_PROMPT_TEMPLATE ) . render ( query = query , languages = languages )
2025-07-04 15:59:41 +08:00
2026-07-01 07:00:54 +05:30
ans = await chat_mdl . async_chat ( rendered_sys_prompt , [ { " role " : " user " , " content " : rendered_user_prompt } ] , { " temperature " : 0.2 } )
2025-05-09 15:32:02 +08:00
if ans . find ( " **ERROR** " ) > = 0 :
2026-06-08 11:49:37 +08:00
logging . info ( " [cross_languages] LLM returned error, falling back to original query " )
2025-05-09 15:32:02 +08:00
return query
2026-06-08 11:49:37 +08:00
ans = re . sub ( r " ^.* \ * \ *ERROR \ * \ * " , " " , ans , flags = re . DOTALL )
result = " \n " . join ( [ a for a in re . sub ( r " (^Output:| \ n+) " , " " , ans , flags = re . DOTALL ) . split ( " === " ) if a . strip ( ) ] )
return result
2025-05-09 15:32:02 +08:00
2025-02-26 15:40:52 +08:00
2025-12-11 17:38:17 +08:00
async def content_tagging ( chat_mdl , content , all_tags , examples , topn = 3 ) :
2025-07-04 15:59:41 +08:00
template = PROMPT_JINJA_ENV . from_string ( CONTENT_TAGGING_PROMPT_TEMPLATE )
2025-02-26 15:40:52 +08:00
2025-07-04 15:59:41 +08:00
for ex in examples :
ex [ " tags_json " ] = json . dumps ( ex [ TAG_FLD ] , indent = 2 , ensure_ascii = False )
2025-02-26 15:40:52 +08:00
2025-07-04 15:59:41 +08:00
rendered_prompt = template . render (
topn = topn ,
all_tags = all_tags ,
examples = examples ,
content = content ,
)
2025-02-26 15:40:52 +08:00
2025-07-04 15:59:41 +08:00
msg = [ { " role " : " system " , " content " : rendered_prompt } , { " role " : " user " , " content " : " Output: " } ]
2025-02-26 15:40:52 +08:00
_ , msg = message_fit_in ( msg , chat_mdl . max_length )
2025-12-11 17:38:17 +08:00
kwd = await chat_mdl . async_chat ( rendered_prompt , msg [ 1 : ] , { " temperature " : 0.5 } )
2025-02-26 15:40:52 +08:00
if isinstance ( kwd , tuple ) :
kwd = kwd [ 0 ]
2025-04-24 11:44:10 +08:00
kwd = re . sub ( r " ^.*</think> " , " " , kwd , flags = re . DOTALL )
2025-02-26 15:40:52 +08:00
if kwd . find ( " **ERROR** " ) > = 0 :
raise Exception ( kwd )
try :
2025-04-25 14:38:34 +08:00
obj = json_repair . loads ( kwd )
2025-02-26 15:40:52 +08:00
except json_repair . JSONDecodeError :
try :
2025-07-04 15:59:41 +08:00
result = kwd . replace ( rendered_prompt [ : - 1 ] , " " ) . replace ( " user " , " " ) . replace ( " model " , " " ) . strip ( )
2025-04-15 09:33:53 +08:00
result = " { " + result . split ( " { " ) [ 1 ] . split ( " } " ) [ 0 ] + " } "
2025-04-25 14:38:34 +08:00
obj = json_repair . loads ( result )
2025-02-26 15:40:52 +08:00
except Exception as e :
logging . exception ( f " JSON parsing error: { result } -> { e } " )
raise e
2025-04-25 14:38:34 +08:00
res = { }
for k , v in obj . items ( ) :
try :
2025-06-23 14:10:13 +08:00
if int ( v ) > 0 :
res [ str ( k ) ] = int ( v )
2025-04-25 14:38:34 +08:00
except Exception :
pass
return res
2025-03-18 14:52:20 +08:00
def vision_llm_describe_prompt ( page = None ) - > str :
2025-07-04 15:59:41 +08:00
template = PROMPT_JINJA_ENV . from_string ( VISION_LLM_DESCRIBE_PROMPT )
return template . render ( page = page )
2025-03-20 09:39:32 +08:00
def vision_llm_figure_describe_prompt ( ) - > str :
2025-07-04 15:59:41 +08:00
template = PROMPT_JINJA_ENV . from_string ( VISION_LLM_FIGURE_DESCRIBE_PROMPT )
return template . render ( )
2026-01-05 09:55:43 +08:00
def vision_llm_figure_describe_prompt_with_context ( context_above : str , context_below : str ) - > str :
template = PROMPT_JINJA_ENV . from_string ( VISION_LLM_FIGURE_DESCRIBE_PROMPT_WITH_CONTEXT )
return template . render ( context_above = context_above , context_below = context_below )
2025-07-30 19:41:09 +08:00
def tool_schema ( tools_description : list [ dict ] , complete_task = False ) :
if not tools_description :
return " "
desc = { }
if complete_task :
desc [ COMPLETE_TASK ] = {
" type " : " function " ,
" function " : {
" name " : COMPLETE_TASK ,
" description " : " When you have the final answer and are ready to complete the task, call this function with your answer " ,
2026-07-01 07:00:54 +05:30
" parameters " : { " type " : " object " , " properties " : { " answer " : { " type " : " string " , " description " : " The final answer to the user ' s question " } } , " required " : [ " answer " ] } ,
} ,
2025-07-30 19:41:09 +08:00
}
2025-12-23 14:08:25 +08:00
for idx , tool in enumerate ( tools_description ) :
name = tool [ " function " ] [ " name " ]
2025-12-24 13:26:48 +08:00
desc [ name ] = tool
2025-07-30 19:41:09 +08:00
2026-07-01 07:00:54 +05:30
return " \n \n " . join ( [ f " ## { i + 1 } . { fnm } \n { json . dumps ( des , ensure_ascii = False , indent = 4 ) } " for i , ( fnm , des ) in enumerate ( desc . items ( ) ) ] )
2025-07-30 19:41:09 +08:00
def form_history ( history , limit = - 6 ) :
context = " "
for h in history [ limit : ] :
if h [ " role " ] == " system " :
continue
role = " USER "
2025-12-29 12:01:18 +08:00
if h [ " role " ] . upper ( ) != role :
2025-07-30 19:41:09 +08:00
role = " AGENT "
2025-12-29 12:01:18 +08:00
context + = f " \n { role } : { h [ ' content ' ] [ : 2048 ] + ( ' ... ' if len ( h [ ' content ' ] ) > 2048 else ' ' ) } "
2025-07-30 19:41:09 +08:00
return context
2026-07-01 07:00:54 +05:30
async def analyze_task_async ( chat_mdl , prompt , task_name , tools_description : list [ dict ] , user_defined_prompts : dict = { } ) :
2025-07-30 19:41:09 +08:00
tools_desc = tool_schema ( tools_description )
context = " "
2025-09-09 19:45:10 +08:00
if user_defined_prompts . get ( " task_analysis " ) :
template = PROMPT_JINJA_ENV . from_string ( user_defined_prompts [ " task_analysis " ] )
else :
template = PROMPT_JINJA_ENV . from_string ( ANALYZE_TASK_SYSTEM + " \n \n " + ANALYZE_TASK_USER )
2025-08-11 17:05:06 +08:00
context = template . render ( task = task_name , context = context , agent_prompt = prompt , tools_desc = tools_desc )
2025-12-11 17:38:17 +08:00
kwd = await chat_mdl . async_chat ( context , [ { " role " : " user " , " content " : " Please analyze it. " } ] )
2025-07-30 19:41:09 +08:00
if isinstance ( kwd , tuple ) :
kwd = kwd [ 0 ]
kwd = re . sub ( r " ^.*</think> " , " " , kwd , flags = re . DOTALL )
if kwd . find ( " **ERROR** " ) > = 0 :
return " "
return kwd
2026-07-01 07:00:54 +05:30
async def next_step_async ( chat_mdl , history : list , tools_description : list [ dict ] , task_desc , user_defined_prompts : dict = { } ) :
2025-07-30 19:41:09 +08:00
if not tools_description :
2025-12-04 14:15:05 +08:00
return " " , 0
2025-07-30 19:41:09 +08:00
desc = tool_schema ( tools_description )
2025-09-09 19:45:10 +08:00
template = PROMPT_JINJA_ENV . from_string ( user_defined_prompts . get ( " plan_generation " , NEXT_STEP ) )
2025-07-30 19:41:09 +08:00
user_prompt = " \n What ' s the next tool to call? If ready OR IMPOSSIBLE TO BE READY, then call `complete_task`. "
hist = deepcopy ( history )
if hist [ - 1 ] [ " role " ] == " user " :
hist [ - 1 ] [ " content " ] + = user_prompt
else :
hist . append ( { " role " : " user " , " content " : user_prompt } )
2025-12-11 17:38:17 +08:00
json_str = await chat_mdl . async_chat (
2025-12-04 14:15:05 +08:00
template . render ( task_analysis = task_desc , desc = desc , today = datetime . datetime . now ( ) . strftime ( " % Y- % m- %d " ) ) ,
hist [ 1 : ] ,
stop = [ " <|stop|> " ] ,
)
2025-07-30 19:41:09 +08:00
tk_cnt = num_tokens_from_string ( json_str )
json_str = re . sub ( r " ^.*</think> " , " " , json_str , flags = re . DOTALL )
return json_str , tk_cnt
2025-12-29 12:01:18 +08:00
async def reflect_async ( chat_mdl , history : list [ dict ] , tool_call_res : list [ Tuple ] , user_defined_prompts : dict = { } ) :
2025-07-30 19:41:09 +08:00
tool_calls = [ { " name " : p [ 0 ] , " result " : p [ 1 ] } for p in tool_call_res ]
goal = history [ 1 ] [ " content " ]
2025-09-09 19:45:10 +08:00
template = PROMPT_JINJA_ENV . from_string ( user_defined_prompts . get ( " reflection " , REFLECT ) )
2025-07-30 19:41:09 +08:00
user_prompt = template . render ( goal = goal , tool_calls = tool_calls )
hist = deepcopy ( history )
if hist [ - 1 ] [ " role " ] == " user " :
hist [ - 1 ] [ " content " ] + = user_prompt
else :
hist . append ( { " role " : " user " , " content " : user_prompt } )
_ , msg = message_fit_in ( hist , chat_mdl . max_length )
2025-12-11 17:38:17 +08:00
ans = await chat_mdl . async_chat ( msg [ 0 ] [ " content " ] , msg [ 1 : ] )
2025-07-30 19:41:09 +08:00
ans = re . sub ( r " ^.*</think> " , " " , ans , flags = re . DOTALL )
return """
* * Observation * *
{ }
* * Reflection * *
{ }
""" .format(json.dumps(tool_calls, ensure_ascii=False, indent=2), ans)
def form_message ( system_prompt , user_prompt ) :
2025-12-29 12:01:18 +08:00
return [ { " role " : " system " , " content " : system_prompt } , { " role " : " user " , " content " : user_prompt } ]
2025-07-30 19:41:09 +08:00
2025-11-03 09:39:53 +08:00
def structured_output_prompt ( schema = None ) - > str :
template = PROMPT_JINJA_ENV . from_string ( STRUCTURED_OUTPUT_PROMPT )
return template . render ( schema = schema )
2025-12-29 12:01:18 +08:00
async def tool_call_summary ( chat_mdl , name : str , params : dict , result : str , user_defined_prompts : dict = { } ) - > str :
2025-07-30 19:41:09 +08:00
template = PROMPT_JINJA_ENV . from_string ( SUMMARY4MEMORY )
2026-07-01 07:00:54 +05:30
system_prompt = template . render ( name = name , params = json . dumps ( params , ensure_ascii = False , indent = 2 ) , result = result )
2025-07-30 19:41:09 +08:00
user_prompt = " → Summary: "
_ , msg = message_fit_in ( form_message ( system_prompt , user_prompt ) , chat_mdl . max_length )
2025-12-11 17:38:17 +08:00
ans = await chat_mdl . async_chat ( msg [ 0 ] [ " content " ] , msg [ 1 : ] )
2025-07-30 19:41:09 +08:00
return re . sub ( r " ^.*</think> " , " " , ans , flags = re . DOTALL )
2026-07-01 07:00:54 +05:30
async def rank_memories_async ( chat_mdl , goal : str , sub_goal : str , tool_call_summaries : list [ str ] , user_defined_prompts : dict = { } ) :
2025-07-30 19:41:09 +08:00
template = PROMPT_JINJA_ENV . from_string ( RANK_MEMORY )
2026-07-01 07:00:54 +05:30
system_prompt = template . render ( goal = goal , sub_goal = sub_goal , results = [ { " i " : i , " content " : s } for i , s in enumerate ( tool_call_summaries ) ] )
2025-07-30 19:41:09 +08:00
user_prompt = " → rank: "
_ , msg = message_fit_in ( form_message ( system_prompt , user_prompt ) , chat_mdl . max_length )
2025-12-11 17:38:17 +08:00
ans = await chat_mdl . async_chat ( msg [ 0 ] [ " content " ] , msg [ 1 : ] , stop = " <|stop|> " )
2025-07-30 19:41:09 +08:00
return re . sub ( r " ^.*</think> " , " " , ans , flags = re . DOTALL )
2025-08-12 14:12:56 +08:00
Support operator constraints in semi-automatic metadata filtering (#12956)
### What problem does this PR solve?
#### Summary
This PR enhances the Semi-automatic metadata filtering mode by allowing
users to explicitly pre-define operators (e.g., contains, =, >, etc.)
for selected metadata keys. While the LLM still dynamically extracts the
filter value from the user's query, it is now strictly constrained to
use the operator specified in the UI configuration.
Using this feature is optional. By default the operator selection is set
to "automatic" resulting in the LLM choosing the operator (as
presently).
#### Rationale & Use Case
This enhancement was driven by a concrete challenge I encountered while
working with technical documentation.
In my specific use case, I was trying to filter for software versions
within a technical manual. In this dataset, a single document chunk
often applies to multiple software versions. These versions are stored
as a combined string within the metadata for each chunk.
When using the standard semi-automatic filter, the LLM would
inconsistently choose between the contains and equals operators. When it
chose equals, it would exclude every chunk that applied to more than one
version, even if the version I was searching for was clearly included in
that metadata string. This led to incomplete and frustrating retrieval
results.
By extending the semi-automatic filter to allow pre-defining the
operator for a specific key, I was able to force the use of contains for
the version field. This change immediately led to significantly improved
and more reliable results in my case.
I believe this functionality will be equally useful for others dealing
with "tagged" or multi-value metadata where the relationship between the
query and the field is known, but the specific value needs to remain
dynamic.
#### Key Changes
##### Backend & Core Logic
- `common/metadata_utils.py`: Updated apply_meta_data_filter to support
a mixed data structure for semi_auto (handling both legacy string arrays
and the new object-based format {"key": "...", "op": "..."}).
- `rag/prompts/generator.py`: Extended gen_meta_filter to accept and
pass operator constraints to the LLM.
- `rag/prompts/meta_filter.md`: Updated the system prompt to instruct
the LLM to strictly respect provided operator constraints.
##### Frontend
- `web/src/components/metadata-filter/metadata-semi-auto-fields.tsx`:
Enhanced the UI to include an operator dropdown for each selected
metadata key, utilizing existing operator constants.
- `web/src/components/metadata-filter/index.tsx`: Updated the validation
schema to accommodate the new state structure.
#### Test Plan
- Backward Compatibility: Verified that existing semi-auto filters
stored as simple strings still function correctly.
- Prompt Verification: Confirmed that constraints are correctly rendered
in the LLM system prompt when specified.
- Added unit tests as
`test/unit_test/common/test_apply_semi_auto_meta_data_filter.py`
- Manual End-to-End:
- Configured a "Semi-automatic" filter for a "Version" key with the
"contains" operator.
- Asked a version-specific query.
- Result
<img width="1173" height="704" alt="Screenshot 2026-02-02 145359"
src="https://github.com/user-attachments/assets/510a6a61-a231-4dc2-a7fe-cdfc07219132"
/>
### Type of change
- [ ] Bug Fix (non-breaking change which fixes an issue)
- [x] New Feature (non-breaking change which adds functionality)
- [ ] Documentation Update
- [ ] Refactoring
- [ ] Performance Improvement
- [ ] Other (please describe):
---------
Co-authored-by: Philipp Heyken Soares <philipp.heyken-soares@am.ai>
2026-02-03 04:11:34 +01:00
async def gen_meta_filter ( chat_mdl , meta_data : dict , query : str , constraints : dict = None ) - > dict :
2026-05-18 14:22:04 +08:00
""" Generate metadata filter conditions from a user query using an LLM.
Args :
chat_mdl : LLM bundle for generating filters
meta_data : Dict of { key : set of values } - e . g . { " character " : { " Caocao " , " Liubei " } , " year " : { 2026 } }
query : User question ( e . g . " Caocao in 2026 " )
constraints : Optional dict of { key : operator } to constrain which op to use for a key
Returns :
Dict with " logic " ( " and " / " or " ) and " conditions " list .
Example return value :
{
" logic " : " and " ,
" conditions " : [
{ " key " : " year " , " value " : " 2026 " , " op " : " = " } ,
{ " key " : " character " , " value " : " Caocao " , " op " : " = " }
]
}
The LLM is prompted with the available metadata keys and values , and is asked to
generate filter conditions that match the user ' s query semantics.
"""
2025-11-28 13:30:53 +08:00
meta_data_structure = { }
for key , values in meta_data . items ( ) :
meta_data_structure [ key ] = list ( values . keys ( ) ) if isinstance ( values , dict ) else values
2025-08-12 14:12:56 +08:00
sys_prompt = PROMPT_JINJA_ENV . from_string ( META_FILTER ) . render (
2026-07-01 07:00:54 +05:30
current_date = datetime . datetime . today ( ) . strftime ( " % Y- % m- %d " ) , metadata_keys = json . dumps ( meta_data_structure ) , user_question = query , constraints = json . dumps ( constraints ) if constraints else None
2025-08-12 14:12:56 +08:00
)
user_prompt = " Generate filters: "
2025-12-11 17:38:17 +08:00
ans = await chat_mdl . async_chat ( sys_prompt , [ { " role " : " user " , " content " : user_prompt } ] )
2025-08-12 14:12:56 +08:00
ans = re . sub ( r " (^.*</think>|```json \ n|``` \ n*$) " , " " , ans , flags = re . DOTALL )
try :
ans = json_repair . loads ( ans )
2025-11-20 14:31:12 +08:00
assert isinstance ( ans , dict ) , ans
assert " conditions " in ans and isinstance ( ans [ " conditions " ] , list ) , ans
2025-08-12 14:12:56 +08:00
return ans
except Exception :
logging . exception ( f " Loading json failure: { ans } " )
2025-11-20 14:31:12 +08:00
return { " conditions " : [ ] }
2025-10-09 12:36:19 +08:00
2026-01-13 09:41:35 +08:00
async def gen_json ( system_prompt : str , user_prompt : str , chat_mdl , gen_conf = { } , max_retry = 2 ) :
2026-01-29 14:23:26 +08:00
from rag . graphrag . utils import get_llm_cache , set_llm_cache
2026-07-01 07:00:54 +05:30
2025-10-10 17:07:55 +08:00
cached = get_llm_cache ( chat_mdl . llm_name , system_prompt , user_prompt , gen_conf )
if cached :
return json_repair . loads ( cached )
2025-10-09 12:36:19 +08:00
_ , msg = message_fit_in ( form_message ( system_prompt , user_prompt ) , chat_mdl . max_length )
2026-01-13 09:41:35 +08:00
err = " "
ans = " "
for _ in range ( max_retry ) :
if ans and err :
msg [ - 1 ] [ " content " ] + = f " \n Generated JSON is as following: \n { ans } \n But exception while loading: \n { err } \n Please reconsider and correct it. "
ans = await chat_mdl . async_chat ( msg [ 0 ] [ " content " ] , msg [ 1 : ] , gen_conf = gen_conf )
ans = re . sub ( r " (^.*</think>|```json \ n|``` \ n*$) " , " " , ans , flags = re . DOTALL )
try :
res = json_repair . loads ( ans )
set_llm_cache ( chat_mdl . llm_name , system_prompt , ans , user_prompt , gen_conf )
return res
except Exception as e :
logging . exception ( f " Loading json failure: { ans } " )
err + = str ( e )
2025-10-09 12:36:19 +08:00
TOC_DETECTION = load_prompt ( " toc_detection " )
2025-12-29 12:01:18 +08:00
async def detect_table_of_contents ( page_1024 : list [ str ] , chat_mdl ) :
2025-10-09 12:36:19 +08:00
toc_secs = [ ]
for i , sec in enumerate ( page_1024 [ : 22 ] ) :
2026-07-01 07:00:54 +05:30
ans = await gen_json ( PROMPT_JINJA_ENV . from_string ( TOC_DETECTION ) . render ( page_txt = sec ) , " Only JSON please. " , chat_mdl )
2025-10-09 12:36:19 +08:00
if toc_secs and not ans [ " exists " ] :
break
toc_secs . append ( sec )
return toc_secs
TOC_EXTRACTION = load_prompt ( " toc_extraction " )
TOC_EXTRACTION_CONTINUE = load_prompt ( " toc_extraction_continue " )
2025-12-29 12:01:18 +08:00
2025-12-11 17:38:17 +08:00
async def extract_table_of_contents ( toc_pages , chat_mdl ) :
2025-10-09 12:36:19 +08:00
if not toc_pages :
return [ ]
2026-07-01 07:00:54 +05:30
return await gen_json ( PROMPT_JINJA_ENV . from_string ( TOC_EXTRACTION ) . render ( toc_page = " \n " . join ( toc_pages ) ) , " Only JSON please. " , chat_mdl )
2025-10-09 12:36:19 +08:00
2025-12-29 12:01:18 +08:00
async def toc_index_extractor ( toc : list [ dict ] , content : str , chat_mdl ) :
2025-10-09 12:36:19 +08:00
tob_extractor_prompt = """
You are given a table of contents in a json format and several pages of a document , your job is to add the physical_index to the table of contents in the json format .
The provided pages contains tags like < physical_index_X > and < physical_index_X > to indicate the physical location of the page X .
The structure variable is the numeric system which represents the index of the hierarchy section in the table of contents . For example , the first section has structure index 1 , the first subsection has structure index 1.1 , the second subsection has structure index 1.2 , etc .
2025-12-04 14:15:05 +08:00
The response should be in the following JSON format :
2025-10-09 12:36:19 +08:00
[
{
" structure " : < structure index , " x.x.x " or None > ( string ) ,
" title " : < title of the section > ,
" physical_index " : " <physical_index_X> " ( keep the format )
} ,
. . .
]
Only add the physical_index to the sections that are in the provided pages .
If the title of the section are not in the provided pages , do not add the physical_index to it .
Directly return the final JSON structure . Do not output anything else . """
2026-07-01 07:00:54 +05:30
prompt = tob_extractor_prompt + " \n Table of contents: \n " + json . dumps ( toc , ensure_ascii = False , indent = 2 ) + " \n Document pages: \n " + content
2025-12-11 17:38:17 +08:00
return await gen_json ( prompt , " Only JSON please. " , chat_mdl )
2025-10-09 12:36:19 +08:00
TOC_INDEX = load_prompt ( " toc_index " )
2025-12-29 12:01:18 +08:00
2025-12-11 17:38:17 +08:00
async def table_of_contents_index ( toc_arr : list [ dict ] , sections : list [ str ] , chat_mdl ) :
2025-10-09 12:36:19 +08:00
if not toc_arr or not sections :
return [ ]
toc_map = { }
for i , it in enumerate ( toc_arr ) :
2025-12-29 12:01:18 +08:00
k1 = ( it [ " structure " ] + it [ " title " ] ) . replace ( " " , " " )
2025-10-09 12:36:19 +08:00
k2 = it [ " title " ] . strip ( )
if k1 not in toc_map :
toc_map [ k1 ] = [ ]
if k2 not in toc_map :
toc_map [ k2 ] = [ ]
toc_map [ k1 ] . append ( i )
toc_map [ k2 ] . append ( i )
for it in toc_arr :
it [ " indices " ] = [ ]
for i , sec in enumerate ( sections ) :
sec = sec . strip ( )
if sec . replace ( " " , " " ) in toc_map :
for j in toc_map [ sec . replace ( " " , " " ) ] :
toc_arr [ j ] [ " indices " ] . append ( i )
all_pathes = [ ]
2025-12-29 12:01:18 +08:00
2025-10-09 12:36:19 +08:00
def dfs ( start , path ) :
nonlocal all_pathes
if start > = len ( toc_arr ) :
if path :
all_pathes . append ( path )
return
if not toc_arr [ start ] [ " indices " ] :
2025-12-29 12:01:18 +08:00
dfs ( start + 1 , path )
2025-10-09 12:36:19 +08:00
return
added = False
for j in toc_arr [ start ] [ " indices " ] :
if path and j < path [ - 1 ] [ 0 ] :
continue
_path = deepcopy ( path )
_path . append ( ( j , start ) )
added = True
2025-12-29 12:01:18 +08:00
dfs ( start + 1 , _path )
2025-10-09 12:36:19 +08:00
if not added and path :
all_pathes . append ( path )
dfs ( 0 , [ ] )
2025-12-29 12:01:18 +08:00
path = max ( all_pathes , key = lambda x : len ( x ) )
2025-10-09 12:36:19 +08:00
for it in toc_arr :
it [ " indices " ] = [ ]
for j , i in path :
toc_arr [ i ] [ " indices " ] = [ j ]
print ( json . dumps ( toc_arr , ensure_ascii = False , indent = 2 ) )
i = 0
while i < len ( toc_arr ) :
2025-12-29 12:01:18 +08:00
it = toc_arr [ i ]
2025-10-09 12:36:19 +08:00
if it [ " indices " ] :
i + = 1
continue
2025-12-29 12:01:18 +08:00
if i > 0 and toc_arr [ i - 1 ] [ " indices " ] :
st_i = toc_arr [ i - 1 ] [ " indices " ] [ - 1 ]
2025-10-09 12:36:19 +08:00
else :
st_i = 0
e = i + 1
2025-12-29 12:01:18 +08:00
while e < len ( toc_arr ) and not toc_arr [ e ] [ " indices " ] :
2025-10-09 12:36:19 +08:00
e + = 1
if e > = len ( toc_arr ) :
e = len ( sections )
else :
e = toc_arr [ e ] [ " indices " ] [ 0 ]
2025-12-29 12:01:18 +08:00
for j in range ( st_i , min ( e + 1 , len ( sections ) ) ) :
2026-07-01 07:00:54 +05:30
ans = await gen_json ( PROMPT_JINJA_ENV . from_string ( TOC_INDEX ) . render ( structure = it [ " structure " ] , title = it [ " title " ] , text = sections [ j ] ) , " Only JSON please. " , chat_mdl )
2025-10-09 12:36:19 +08:00
if ans [ " exist " ] == " yes " :
it [ " indices " ] . append ( j )
break
i + = 1
return toc_arr
2025-12-11 17:38:17 +08:00
async def check_if_toc_transformation_is_complete ( content , toc , chat_mdl ) :
2025-10-09 12:36:19 +08:00
prompt = """
You are given a raw table of contents and a table of contents .
Your job is to check if the table of contents is complete .
Reply format :
{ {
" thinking " : < why do you think the cleaned table of contents is complete or not >
" completed " : " yes " or " no "
} }
Directly return the final JSON structure . Do not output anything else . """
2026-07-01 07:00:54 +05:30
prompt = prompt + " \n Raw Table of contents: \n " + content + " \n Cleaned Table of contents: \n " + toc
2025-12-11 17:38:17 +08:00
response = await gen_json ( prompt , " Only JSON please. " , chat_mdl )
2026-07-01 07:00:54 +05:30
return response [ " completed " ]
2025-10-09 12:36:19 +08:00
2025-12-11 17:38:17 +08:00
async def toc_transformer ( toc_pages , chat_mdl ) :
2025-10-09 12:36:19 +08:00
init_prompt = """
You are given a table of contents , You job is to transform the whole table of content into a JSON format included table_of_contents .
The ` structure ` is the numeric system which represents the index of the hierarchy section in the table of contents . For example , the first section has structure index 1 , the first subsection has structure index 1.1 , the second subsection has structure index 1.2 , etc .
The ` title ` is a short phrase or a several - words term .
2025-12-04 14:15:05 +08:00
The response should be in the following JSON format :
2025-10-09 12:36:19 +08:00
[
{
" structure " : < structure index , " x.x.x " or None > ( string ) ,
" title " : < title of the section >
} ,
. . .
] ,
You should transform the full table of contents in one go .
Directly return the final JSON structure , do not output anything else . """
toc_content = " \n " . join ( toc_pages )
2026-07-01 07:00:54 +05:30
prompt = init_prompt + " \n Given table of contents \n : " + toc_content
2025-12-29 12:01:18 +08:00
2025-10-09 12:36:19 +08:00
def clean_toc ( arr ) :
for a in arr :
a [ " title " ] = re . sub ( r " [.·….] { 2,} " , " " , a [ " title " ] )
2025-12-29 12:01:18 +08:00
2025-12-11 17:38:17 +08:00
last_complete = await gen_json ( prompt , " Only JSON please. " , chat_mdl )
2026-07-01 07:00:54 +05:30
if_complete = await check_if_toc_transformation_is_complete ( toc_content , json . dumps ( last_complete , ensure_ascii = False , indent = 2 ) , chat_mdl )
2025-10-09 12:36:19 +08:00
clean_toc ( last_complete )
if if_complete == " yes " :
return last_complete
while not ( if_complete == " yes " ) :
prompt = f """
Your task is to continue the table of contents json structure , directly output the remaining part of the json structure .
2025-12-04 14:15:05 +08:00
The response should be in the following JSON format :
2025-10-09 12:36:19 +08:00
The raw table of contents json structure is :
{ toc_content }
The incomplete transformed table of contents json structure is :
{ json . dumps ( last_complete [ - 24 : ] , ensure_ascii = False , indent = 2 ) }
Please continue the json structure , directly output the remaining part of the json structure . """
2025-12-11 17:38:17 +08:00
new_complete = await gen_json ( prompt , " Only JSON please. " , chat_mdl )
2025-10-09 12:36:19 +08:00
if not new_complete or str ( last_complete ) . find ( str ( new_complete ) ) > = 0 :
break
clean_toc ( new_complete )
last_complete . extend ( new_complete )
2026-07-01 07:00:54 +05:30
if_complete = await check_if_toc_transformation_is_complete ( toc_content , json . dumps ( last_complete , ensure_ascii = False , indent = 2 ) , chat_mdl )
2025-10-09 12:36:19 +08:00
return last_complete
2025-10-09 13:47:31 +08:00
TOC_LEVELS = load_prompt ( " assign_toc_levels " )
2025-12-29 12:01:18 +08:00
async def assign_toc_levels ( toc_secs , chat_mdl , gen_conf = { " temperature " : 0.2 } ) :
2025-10-10 17:07:55 +08:00
if not toc_secs :
return [ ]
2026-07-01 07:00:54 +05:30
return await gen_json ( PROMPT_JINJA_ENV . from_string ( TOC_LEVELS ) . render ( ) , str ( toc_secs ) , chat_mdl , gen_conf )
2025-10-09 13:47:31 +08:00
TOC_FROM_TEXT_SYSTEM = load_prompt ( " toc_from_text_system " )
TOC_FROM_TEXT_USER = load_prompt ( " toc_from_text_user " )
2025-12-29 12:01:18 +08:00
2025-10-09 13:47:31 +08:00
# Generate TOC from text chunks with text llms
2025-10-11 18:45:21 +08:00
async def gen_toc_from_text ( txt_info : dict , chat_mdl , callback = None ) :
2025-12-30 20:24:27 +08:00
if callback :
callback ( msg = " " )
2025-10-10 17:07:55 +08:00
try :
2025-12-11 17:38:17 +08:00
ans = await gen_json (
2025-10-10 17:07:55 +08:00
PROMPT_JINJA_ENV . from_string ( TOC_FROM_TEXT_SYSTEM ) . render ( ) ,
2026-07-01 07:00:54 +05:30
PROMPT_JINJA_ENV . from_string ( TOC_FROM_TEXT_USER ) . render ( text = " \n " . join ( [ json . dumps ( d , ensure_ascii = False ) for d in txt_info [ " chunks " ] ] ) ) ,
2025-10-10 17:07:55 +08:00
chat_mdl ,
2026-07-01 07:00:54 +05:30
gen_conf = { " temperature " : 0.0 , " top_p " : 0.9 } ,
2025-10-10 17:07:55 +08:00
)
2025-10-14 14:14:52 +08:00
txt_info [ " toc " ] = ans if ans and not isinstance ( ans , str ) else [ ]
2025-10-10 17:07:55 +08:00
except Exception as e :
logging . exception ( e )
2025-10-09 13:47:31 +08:00
def split_chunks ( chunks , max_length : int ) :
"""
Pack chunks into batches according to max_length , returning [ { " id " : idx , " text " : chunk_text } , . . . ] .
Do not split a single chunk , even if it exceeds max_length .
"""
result = [ ]
batch , batch_tokens = [ ] , 0
for idx , chunk in enumerate ( chunks ) :
t = num_tokens_from_string ( chunk )
if batch_tokens + t > max_length :
result . append ( batch )
batch , batch_tokens = [ ] , 0
2025-10-10 17:07:55 +08:00
batch . append ( { idx : chunk } )
2025-10-09 13:47:31 +08:00
batch_tokens + = t
if batch :
result . append ( batch )
return result
2025-10-11 18:45:21 +08:00
async def run_toc_from_text ( chunks , chat_mdl , callback = None ) :
2026-07-01 07:00:54 +05:30
input_budget = int ( chat_mdl . max_length * INPUT_UTILIZATION ) - num_tokens_from_string ( TOC_FROM_TEXT_USER + TOC_FROM_TEXT_SYSTEM )
2025-10-09 13:47:31 +08:00
2025-12-29 12:01:18 +08:00
input_budget = 1024 if input_budget > 1024 else input_budget
2025-10-09 13:47:31 +08:00
chunk_sections = split_chunks ( chunks , input_budget )
2025-10-11 18:45:21 +08:00
titles = [ ]
2025-10-09 13:47:31 +08:00
2025-10-10 17:07:55 +08:00
chunks_res = [ ]
2025-12-09 19:23:14 +08:00
tasks = [ ]
for i , chunk in enumerate ( chunk_sections ) :
if not chunk :
continue
chunks_res . append ( { " chunks " : chunk } )
tasks . append ( asyncio . create_task ( gen_toc_from_text ( chunks_res [ - 1 ] , chat_mdl , callback ) ) )
try :
await asyncio . gather ( * tasks , return_exceptions = False )
except Exception as e :
logging . error ( f " Error generating TOC: { e } " )
for t in tasks :
t . cancel ( )
await asyncio . gather ( * tasks , return_exceptions = True )
raise
2025-10-10 17:07:55 +08:00
for chunk in chunks_res :
2025-10-11 18:45:21 +08:00
titles . extend ( chunk . get ( " toc " , [ ] ) )
2025-12-04 14:15:05 +08:00
2025-10-09 13:47:31 +08:00
# Filter out entries with title == -1
2025-10-11 18:45:21 +08:00
prune = len ( titles ) > 512
max_len = 12 if prune else 22
2025-10-10 17:07:55 +08:00
filtered = [ ]
2025-10-11 18:45:21 +08:00
for x in titles :
2025-10-14 19:38:54 +08:00
if not isinstance ( x , dict ) or not x . get ( " title " ) or x [ " title " ] == " -1 " :
2025-10-10 17:07:55 +08:00
continue
2025-10-11 18:45:21 +08:00
if len ( rag_tokenizer . tokenize ( x [ " title " ] ) . split ( " " ) ) > max_len :
2025-10-10 17:07:55 +08:00
continue
if re . match ( r " [0-9,.()/ -]+$ " , x [ " title " ] ) :
continue
filtered . append ( x )
2025-10-09 13:47:31 +08:00
2025-10-10 17:07:55 +08:00
logging . info ( f " \n \n Filtered TOC sections: \n { filtered } " )
2025-10-14 14:14:52 +08:00
if not filtered :
return [ ]
2025-10-09 13:47:31 +08:00
2025-10-10 17:07:55 +08:00
# Generate initial level (level/title)
raw_structure = [ x . get ( " title " , " " ) for x in filtered ]
2025-10-09 13:47:31 +08:00
# Assign hierarchy levels using LLM
2025-12-11 17:38:17 +08:00
toc_with_levels = await assign_toc_levels ( raw_structure , chat_mdl , { " temperature " : 0.0 , " top_p " : 0.9 } )
2025-10-14 14:14:52 +08:00
if not toc_with_levels :
return [ ]
2025-10-09 13:47:31 +08:00
2026-05-27 21:54:17 +08:00
# Normalize TOC items to ensure consistent dict format
normalized_levels = [ ]
for item in toc_with_levels :
if isinstance ( item , dict ) :
# Already in correct format
normalized_levels . append ( item )
elif isinstance ( item , ( list , tuple ) ) and len ( item ) > = 2 :
# Convert ["level", "title"] or similar to dict
normalized_levels . append ( { " level " : str ( item [ 0 ] ) , " title " : str ( item [ 1 ] ) } )
else :
logging . warning ( f " Unexpected TOC item format (type= { type ( item ) . __name__ } ), skipping: { item } " )
toc_with_levels = normalized_levels
if not toc_with_levels :
logging . warning ( " No valid TOC items after normalization. " )
return [ ]
2025-10-09 13:47:31 +08:00
# Merge structure and content (by index)
2025-10-11 18:45:21 +08:00
prune = len ( toc_with_levels ) > 512
2025-12-09 09:58:34 +08:00
max_lvl = " 0 "
sorted_list = sorted ( [ t . get ( " level " , " 0 " ) for t in toc_with_levels if isinstance ( t , dict ) ] )
if sorted_list :
max_lvl = sorted_list [ - 1 ]
2025-10-09 13:47:31 +08:00
merged = [ ]
2025-12-29 12:01:18 +08:00
for _ , ( toc_item , src_item ) in enumerate ( zip ( toc_with_levels , filtered ) ) :
2025-10-11 18:45:21 +08:00
if prune and toc_item . get ( " level " , " 0 " ) > = max_lvl :
continue
2026-07-01 07:00:54 +05:30
merged . append (
{
" level " : toc_item . get ( " level " , " 0 " ) ,
" title " : toc_item . get ( " title " , " " ) ,
" chunk_id " : src_item . get ( " chunk_id " , " " ) ,
}
)
2025-10-09 12:36:19 +08:00
2025-10-10 17:07:55 +08:00
return merged
TOC_RELEVANCE_SYSTEM = load_prompt ( " toc_relevance_system " )
TOC_RELEVANCE_USER = load_prompt ( " toc_relevance_user " )
2026-07-01 07:00:54 +05:30
2025-12-29 12:01:18 +08:00
async def relevant_chunks_with_toc ( query : str , toc : list [ dict ] , chat_mdl , topn : int = 6 ) :
2025-10-10 17:07:55 +08:00
import numpy as np
2026-07-01 07:00:54 +05:30
2025-10-10 17:07:55 +08:00
try :
2025-12-11 17:38:17 +08:00
ans = await gen_json (
2025-10-10 17:07:55 +08:00
PROMPT_JINJA_ENV . from_string ( TOC_RELEVANCE_SYSTEM ) . render ( ) ,
2026-07-01 07:00:54 +05:30
PROMPT_JINJA_ENV . from_string ( TOC_RELEVANCE_USER ) . render (
query = query , toc_json = " [ \n %s \n ] \n " % " \n " . join ( [ json . dumps ( { " level " : d [ " level " ] , " title " : d [ " title " ] } , ensure_ascii = False ) for d in toc ] )
) ,
2025-10-10 17:07:55 +08:00
chat_mdl ,
2026-07-01 07:00:54 +05:30
gen_conf = { " temperature " : 0.0 , " top_p " : 0.9 } ,
2025-10-10 17:07:55 +08:00
)
id2score = { }
for ti , sc in zip ( toc , ans ) :
2025-10-11 18:45:21 +08:00
if not isinstance ( sc , dict ) or sc . get ( " score " , - 1 ) < 1 :
2025-10-10 17:07:55 +08:00
continue
for id in ti . get ( " ids " , [ ] ) :
if id not in id2score :
id2score [ id ] = [ ]
2026-07-01 07:00:54 +05:30
id2score [ id ] . append ( sc [ " score " ] / 5.0 )
2025-10-10 17:07:55 +08:00
for id in id2score . keys ( ) :
id2score [ id ] = np . mean ( id2score [ id ] )
2025-12-29 12:01:18 +08:00
return [ ( id , sc ) for id , sc in list ( id2score . items ( ) ) if sc > = 0.3 ] [ : topn ]
2025-10-10 17:07:55 +08:00
except Exception as e :
logging . exception ( e )
return [ ]
2025-12-17 16:50:36 +08:00
META_DATA = load_prompt ( " meta_data " )
2026-07-01 07:00:54 +05:30
2025-12-29 12:01:18 +08:00
async def gen_metadata ( chat_mdl , schema : dict , content : str ) :
2026-06-09 19:10:48 +08:00
if not schema :
return " "
if " properties " not in schema :
logging . warning ( " gen_metadata: schema has no ' properties ' key: %s " , schema )
return " "
2025-12-17 16:50:36 +08:00
template = PROMPT_JINJA_ENV . from_string ( META_DATA )
2025-12-25 19:01:22 +08:00
for k , desc in schema [ " properties " ] . items ( ) :
2025-12-25 14:06:20 +08:00
if " enum " in desc and not desc . get ( " enum " ) :
del desc [ " enum " ]
if desc . get ( " enum " ) :
desc [ " description " ] + = " \n ** Extracted values must strictly match the given list specified by `enum`. ** "
2025-12-17 16:50:36 +08:00
system_prompt = template . render ( content = content , schema = schema )
user_prompt = " Output: "
_ , msg = message_fit_in ( form_message ( system_prompt , user_prompt ) , chat_mdl . max_length )
ans = await chat_mdl . async_chat ( msg [ 0 ] [ " content " ] , msg [ 1 : ] )
2025-12-29 12:01:18 +08:00
return re . sub ( r " ^.*</think> " , " " , ans , flags = re . DOTALL )
2026-01-13 09:41:35 +08:00
SUFFICIENCY_CHECK = load_prompt ( " sufficiency_check " )
2026-07-01 07:00:54 +05:30
2026-01-13 09:41:35 +08:00
async def sufficiency_check ( chat_mdl , question : str , ret_content : str ) :
try :
2026-07-01 07:00:54 +05:30
return await gen_json ( PROMPT_JINJA_ENV . from_string ( SUFFICIENCY_CHECK ) . render ( question = question , retrieved_docs = ret_content ) , " Output: \n " , chat_mdl )
2026-01-13 09:41:35 +08:00
except Exception as e :
logging . exception ( e )
return { }
MULTI_QUERIES_GEN = load_prompt ( " multi_queries_gen " )
2026-07-01 07:00:54 +05:30
async def multi_queries_gen ( chat_mdl , question : str , query : str , missing_infos : list [ str ] , ret_content : str ) :
2026-01-13 09:41:35 +08:00
try :
return await gen_json (
2026-07-01 07:00:54 +05:30
PROMPT_JINJA_ENV . from_string ( MULTI_QUERIES_GEN ) . render ( original_question = question , original_query = query , missing_info = " \n - " . join ( missing_infos ) , retrieved_docs = ret_content ) ,
2026-01-13 09:41:35 +08:00
" Output: \n " ,
2026-07-01 07:00:54 +05:30
chat_mdl ,
2026-01-13 09:41:35 +08:00
)
except Exception as e :
logging . exception ( e )
2026-01-29 14:23:26 +08:00
return { }