Skip to content

Commit 53af276

Browse files
authored
Merge pull request #584: sample long ontology input across the document
Replace first-50000-character truncation with deterministic sampling under the same context budget.
2 parents ad12d41 + f0e936a commit 53af276

2 files changed

Lines changed: 194 additions & 9 deletions

File tree

backend/app/services/ontology_generator.py

Lines changed: 142 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from typing import Dict, Any, List, Optional
1010
from ..utils.llm_client import LLMClient
1111
from ..utils.locale import get_language_instruction
12+
from ..utils.file_parser import split_text_into_chunks
1213

1314
logger = logging.getLogger(__name__)
1415

@@ -227,6 +228,10 @@ def generate(
227228

228229
# 传给 LLM 的文本最大长度(5万字)
229230
MAX_TEXT_LENGTH_FOR_LLM = 50000
231+
LONG_TEXT_CHUNK_SIZE = 8000
232+
LONG_TEXT_CHUNK_OVERLAP = 200
233+
MAX_LONG_TEXT_CHUNKS = 60
234+
MIN_LONG_TEXT_EXCERPT = 400
230235

231236
def _build_user_message(
232237
self,
@@ -236,14 +241,7 @@ def _build_user_message(
236241
) -> str:
237242
"""构建用户消息"""
238243

239-
# 合并文本
240-
combined_text = "\n\n---\n\n".join(document_texts)
241-
original_length = len(combined_text)
242-
243-
# 如果文本超过5万字,截断(仅影响传给LLM的内容,不影响图谱构建)
244-
if len(combined_text) > self.MAX_TEXT_LENGTH_FOR_LLM:
245-
combined_text = combined_text[:self.MAX_TEXT_LENGTH_FOR_LLM]
246-
combined_text += f"\n\n...(原文共{original_length}字,已截取前{self.MAX_TEXT_LENGTH_FOR_LLM}字用于本体分析)..."
244+
combined_text = self._build_document_context(document_texts)
247245

248246
message = f"""## 模拟需求
249247
@@ -273,6 +271,142 @@ def _build_user_message(
273271
"""
274272

275273
return message
274+
275+
def _build_document_context(self, document_texts: List[str]) -> str:
276+
"""构建用于本体分析的文档上下文,长文本按全局分块抽样而不是只截取开头。"""
277+
278+
combined_text = "\n\n---\n\n".join(document_texts)
279+
original_length = len(combined_text)
280+
281+
if original_length <= self.MAX_TEXT_LENGTH_FOR_LLM:
282+
return combined_text
283+
284+
chunks = self._collect_document_chunks(document_texts)
285+
if not chunks:
286+
return ""
287+
288+
selected_chunks = self._select_representative_chunks(chunks)
289+
excerpt_budget = self._calculate_excerpt_budget(len(selected_chunks))
290+
context = self._render_chunked_context(
291+
selected_chunks=selected_chunks,
292+
original_length=original_length,
293+
total_chunks=len(chunks),
294+
excerpt_limit=excerpt_budget,
295+
)
296+
297+
while len(context) > self.MAX_TEXT_LENGTH_FOR_LLM and excerpt_budget > self.MIN_LONG_TEXT_EXCERPT:
298+
excerpt_budget = max(self.MIN_LONG_TEXT_EXCERPT, int(excerpt_budget * 0.85))
299+
context = self._render_chunked_context(
300+
selected_chunks=selected_chunks,
301+
original_length=original_length,
302+
total_chunks=len(chunks),
303+
excerpt_limit=excerpt_budget,
304+
)
305+
306+
if len(context) > self.MAX_TEXT_LENGTH_FOR_LLM:
307+
marker = "\n\n...(分块上下文已压缩到本体分析长度限制内)..."
308+
context = context[:self.MAX_TEXT_LENGTH_FOR_LLM - len(marker)] + marker
309+
310+
return context
311+
312+
def _collect_document_chunks(self, document_texts: List[str]) -> List[Dict[str, Any]]:
313+
"""按文档收集分块,保留文档和分块编号方便提示词定位。"""
314+
315+
all_chunks: List[Dict[str, Any]] = []
316+
for doc_index, text in enumerate(document_texts, 1):
317+
doc_chunks = split_text_into_chunks(
318+
text,
319+
chunk_size=self.LONG_TEXT_CHUNK_SIZE,
320+
overlap=self.LONG_TEXT_CHUNK_OVERLAP,
321+
)
322+
total_doc_chunks = len(doc_chunks)
323+
for chunk_index, chunk in enumerate(doc_chunks, 1):
324+
all_chunks.append({
325+
"document_index": doc_index,
326+
"chunk_index": chunk_index,
327+
"total_document_chunks": total_doc_chunks,
328+
"text": chunk,
329+
})
330+
331+
return all_chunks
332+
333+
def _select_representative_chunks(self, chunks: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
334+
"""从全部分块中等距抽样,覆盖长文开头、中段和结尾。"""
335+
336+
if len(chunks) <= self.MAX_LONG_TEXT_CHUNKS:
337+
return chunks
338+
339+
if self.MAX_LONG_TEXT_CHUNKS <= 1:
340+
return [chunks[0]]
341+
342+
last_index = len(chunks) - 1
343+
selected_indexes = {
344+
round(i * last_index / (self.MAX_LONG_TEXT_CHUNKS - 1))
345+
for i in range(self.MAX_LONG_TEXT_CHUNKS)
346+
}
347+
return [chunks[i] for i in sorted(selected_indexes)]
348+
349+
def _calculate_excerpt_budget(self, selected_count: int) -> int:
350+
"""根据选中的分块数量为每块分配字符预算。"""
351+
352+
header_budget = 600
353+
chunk_header_budget = 120 * selected_count
354+
available = max(
355+
self.MIN_LONG_TEXT_EXCERPT * selected_count,
356+
self.MAX_TEXT_LENGTH_FOR_LLM - header_budget - chunk_header_budget,
357+
)
358+
return max(self.MIN_LONG_TEXT_EXCERPT, available // max(selected_count, 1))
359+
360+
def _render_chunked_context(
361+
self,
362+
selected_chunks: List[Dict[str, Any]],
363+
original_length: int,
364+
total_chunks: int,
365+
excerpt_limit: int,
366+
) -> str:
367+
"""渲染长文本分块上下文。"""
368+
369+
lines = [
370+
(
371+
f"【长文本自动分块摘要】原文共{original_length}字,"
372+
f"已分为{total_chunks}个文本块用于全局覆盖分析。"
373+
),
374+
(
375+
f"以下展示其中{len(selected_chunks)}个代表性文本块的摘录,"
376+
"覆盖开头、中段和结尾;请基于这些跨全文线索设计本体,不要只依赖第一段内容。"
377+
),
378+
]
379+
380+
for chunk in selected_chunks:
381+
excerpt = self._excerpt_text(chunk["text"], excerpt_limit)
382+
lines.append(
383+
"\n".join([
384+
(
385+
f"--- 文档 {chunk['document_index']} / "
386+
f"分块 {chunk['chunk_index']}/{chunk['total_document_chunks']} ---"
387+
),
388+
excerpt,
389+
])
390+
)
391+
392+
return "\n\n".join(lines)
393+
394+
@staticmethod
395+
def _excerpt_text(text: str, char_limit: int) -> str:
396+
"""长分块保留首尾,避免每个分块内部再次变成只看开头。"""
397+
398+
text = text.strip()
399+
if len(text) <= char_limit:
400+
return text
401+
402+
marker = "\n...(本分块中间内容省略)...\n"
403+
if char_limit <= len(marker) + 20:
404+
return text[:char_limit]
405+
406+
remaining = char_limit - len(marker)
407+
head_len = remaining // 2
408+
tail_len = remaining - head_len
409+
return f"{text[:head_len].rstrip()}{marker}{text[-tail_len:].lstrip()}"
276410

277411
def _validate_and_process(self, result: Dict[str, Any]) -> Dict[str, Any]:
278412
"""验证和后处理结果"""
@@ -503,4 +637,3 @@ def generate_python_code(self, ontology: Dict[str, Any]) -> str:
503637
code_lines.append('}')
504638

505639
return '\n'.join(code_lines)
506-
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
from app.services.ontology_generator import OntologyGenerator
2+
3+
4+
def _generator_for_test() -> OntologyGenerator:
5+
generator = OntologyGenerator(llm_client=object())
6+
generator.MAX_TEXT_LENGTH_FOR_LLM = 2000
7+
generator.LONG_TEXT_CHUNK_SIZE = 500
8+
generator.LONG_TEXT_CHUNK_OVERLAP = 0
9+
generator.MAX_LONG_TEXT_CHUNKS = 3
10+
generator.MIN_LONG_TEXT_EXCERPT = 120
11+
return generator
12+
13+
14+
def test_short_ontology_context_keeps_original_text():
15+
generator = _generator_for_test()
16+
17+
context = generator._build_document_context(["short document body"])
18+
19+
assert context == "short document body"
20+
assert "长文本自动分块摘要" not in context
21+
22+
23+
def test_long_ontology_context_samples_across_document():
24+
generator = _generator_for_test()
25+
long_text = "BEGIN" + ("a" * 1050) + "MIDDLE" + ("b" * 1050) + "END"
26+
27+
context = generator._build_document_context([long_text])
28+
29+
assert len(context) <= generator.MAX_TEXT_LENGTH_FOR_LLM
30+
assert "长文本自动分块摘要" in context
31+
assert "BEGIN" in context
32+
assert "MIDDLE" in context
33+
assert "END" in context
34+
assert "分块 1/" in context
35+
assert "分块 3/" in context
36+
assert "分块 5/" in context
37+
38+
39+
def test_very_long_ontology_context_selects_representative_chunks():
40+
generator = _generator_for_test()
41+
chunks = ["BEGIN"] + [
42+
f"CHUNK{i:02d}-" + (str(i) * 490)
43+
for i in range(12)
44+
] + ["FINALEND"]
45+
long_text = "".join(chunks)
46+
47+
context = generator._build_document_context([long_text])
48+
49+
assert len(context) <= generator.MAX_TEXT_LENGTH_FOR_LLM
50+
assert "BEGIN" in context
51+
assert "FINALEND" in context
52+
assert context.count("--- 文档 1 / 分块") == generator.MAX_LONG_TEXT_CHUNKS

0 commit comments

Comments
 (0)