-
Notifications
You must be signed in to change notification settings - Fork 9.8k
Expand file tree
/
Copy pathontology_generator.py
More file actions
639 lines (526 loc) · 23.8 KB
/
ontology_generator.py
File metadata and controls
639 lines (526 loc) · 23.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
"""
本体生成服务
接口1:分析文本内容,生成适合社会模拟的实体和关系类型定义
"""
import json
import logging
import re
from typing import Dict, Any, List, Optional
from ..utils.llm_client import LLMClient
from ..utils.locale import get_language_instruction
from ..utils.file_parser import split_text_into_chunks
logger = logging.getLogger(__name__)
def _to_pascal_case(name: str) -> str:
"""将任意格式的名称转换为 PascalCase(如 'works_for' -> 'WorksFor', 'person' -> 'Person')"""
# 按非字母数字字符分割
parts = re.split(r'[^a-zA-Z0-9]+', name)
# 再按 camelCase 边界分割(如 'camelCase' -> ['camel', 'Case'])
words = []
for part in parts:
words.extend(re.sub(r'([a-z])([A-Z])', r'\1_\2', part).split('_'))
# 每个词首字母大写,过滤空串
result = ''.join(word.capitalize() for word in words if word)
return result if result else 'Unknown'
# 本体生成的系统提示词
ONTOLOGY_SYSTEM_PROMPT = """你是一个专业的知识图谱本体设计专家。你的任务是分析给定的文本内容和模拟需求,设计适合**社交媒体舆论模拟**的实体类型和关系类型。
**重要:你必须输出有效的JSON格式数据,不要输出任何其他内容。**
## 核心任务背景
我们正在构建一个**社交媒体舆论模拟系统**。在这个系统中:
- 每个实体都是一个可以在社交媒体上发声、互动、传播信息的"账号"或"主体"
- 实体之间会相互影响、转发、评论、回应
- 我们需要模拟舆论事件中各方的反应和信息传播路径
因此,**实体必须是现实中真实存在的、可以在社媒上发声和互动的主体**:
**可以是**:
- 具体的个人(公众人物、当事人、意见领袖、专家学者、普通人)
- 公司、企业(包括其官方账号)
- 组织机构(大学、协会、NGO、工会等)
- 政府部门、监管机构
- 媒体机构(报纸、电视台、自媒体、网站)
- 社交媒体平台本身
- 特定群体代表(如校友会、粉丝团、维权群体等)
**不可以是**:
- 抽象概念(如"舆论"、"情绪"、"趋势")
- 主题/话题(如"学术诚信"、"教育改革")
- 观点/态度(如"支持方"、"反对方")
## 输出格式
请输出JSON格式,包含以下结构:
```json
{
"entity_types": [
{
"name": "实体类型名称(英文,PascalCase)",
"description": "简短描述(英文,不超过100字符)",
"attributes": [
{
"name": "属性名(英文,snake_case)",
"type": "text",
"description": "属性描述"
}
],
"examples": ["示例实体1", "示例实体2"]
}
],
"edge_types": [
{
"name": "关系类型名称(英文,UPPER_SNAKE_CASE)",
"description": "简短描述(英文,不超过100字符)",
"source_targets": [
{"source": "源实体类型", "target": "目标实体类型"}
],
"attributes": []
}
],
"analysis_summary": "对文本内容的简要分析说明"
}
```
## 设计指南(极其重要!)
### 1. 实体类型设计 - 必须严格遵守
**数量要求:必须正好10个实体类型**
**层次结构要求(必须同时包含具体类型和兜底类型)**:
你的10个实体类型必须包含以下层次:
A. **兜底类型(必须包含,放在列表最后2个)**:
- `Person`: 任何自然人个体的兜底类型。当一个人不属于其他更具体的人物类型时,归入此类。
- `Organization`: 任何组织机构的兜底类型。当一个组织不属于其他更具体的组织类型时,归入此类。
B. **具体类型(8个,根据文本内容设计)**:
- 针对文本中出现的主要角色,设计更具体的类型
- 例如:如果文本涉及学术事件,可以有 `Student`, `Professor`, `University`
- 例如:如果文本涉及商业事件,可以有 `Company`, `CEO`, `Employee`
**为什么需要兜底类型**:
- 文本中会出现各种人物,如"中小学教师"、"路人甲"、"某位网友"
- 如果没有专门的类型匹配,他们应该被归入 `Person`
- 同理,小型组织、临时团体等应该归入 `Organization`
**具体类型的设计原则**:
- 从文本中识别出高频出现或关键的角色类型
- 每个具体类型应该有明确的边界,避免重叠
- description 必须清晰说明这个类型和兜底类型的区别
### 2. 关系类型设计
- 数量:6-10个
- 关系应该反映社媒互动中的真实联系
- 确保关系的 source_targets 涵盖你定义的实体类型
### 3. 属性设计
- 每个实体类型1-3个关键属性
- **注意**:属性名不能使用 `name`、`uuid`、`group_id`、`created_at`、`summary`(这些是系统保留字)
- 推荐使用:`full_name`, `title`, `role`, `position`, `location`, `description` 等
## 实体类型参考
**个人类(具体)**:
- Student: 学生
- Professor: 教授/学者
- Journalist: 记者
- Celebrity: 明星/网红
- Executive: 高管
- Official: 政府官员
- Lawyer: 律师
- Doctor: 医生
**个人类(兜底)**:
- Person: 任何自然人(不属于上述具体类型时使用)
**组织类(具体)**:
- University: 高校
- Company: 公司企业
- GovernmentAgency: 政府机构
- MediaOutlet: 媒体机构
- Hospital: 医院
- School: 中小学
- NGO: 非政府组织
**组织类(兜底)**:
- Organization: 任何组织机构(不属于上述具体类型时使用)
## 关系类型参考
- WORKS_FOR: 工作于
- STUDIES_AT: 就读于
- AFFILIATED_WITH: 隶属于
- REPRESENTS: 代表
- REGULATES: 监管
- REPORTS_ON: 报道
- COMMENTS_ON: 评论
- RESPONDS_TO: 回应
- SUPPORTS: 支持
- OPPOSES: 反对
- COLLABORATES_WITH: 合作
- COMPETES_WITH: 竞争
"""
class OntologyGenerator:
"""
本体生成器
分析文本内容,生成实体和关系类型定义
"""
def __init__(self, llm_client: Optional[LLMClient] = None):
self.llm_client = llm_client or LLMClient()
def generate(
self,
document_texts: List[str],
simulation_requirement: str,
additional_context: Optional[str] = None
) -> Dict[str, Any]:
"""
生成本体定义
Args:
document_texts: 文档文本列表
simulation_requirement: 模拟需求描述
additional_context: 额外上下文
Returns:
本体定义(entity_types, edge_types等)
"""
# 构建用户消息
user_message = self._build_user_message(
document_texts,
simulation_requirement,
additional_context
)
lang_instruction = get_language_instruction()
system_prompt = f"{ONTOLOGY_SYSTEM_PROMPT}\n\n{lang_instruction}\nIMPORTANT: Entity type names MUST be in English PascalCase (e.g., 'PersonEntity', 'MediaOrganization'). Relationship type names MUST be in English UPPER_SNAKE_CASE (e.g., 'WORKS_FOR'). Attribute names MUST be in English snake_case. Only description fields and analysis_summary should use the specified language above."
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message}
]
# 调用LLM
result = self.llm_client.chat_json(
messages=messages,
temperature=0.3,
max_tokens=4096
)
# 验证和后处理
result = self._validate_and_process(result)
return result
# 传给 LLM 的文本最大长度(5万字)
MAX_TEXT_LENGTH_FOR_LLM = 50000
LONG_TEXT_CHUNK_SIZE = 8000
LONG_TEXT_CHUNK_OVERLAP = 200
MAX_LONG_TEXT_CHUNKS = 60
MIN_LONG_TEXT_EXCERPT = 400
def _build_user_message(
self,
document_texts: List[str],
simulation_requirement: str,
additional_context: Optional[str]
) -> str:
"""构建用户消息"""
combined_text = self._build_document_context(document_texts)
message = f"""## 模拟需求
{simulation_requirement}
## 文档内容
{combined_text}
"""
if additional_context:
message += f"""
## 额外说明
{additional_context}
"""
message += """
请根据以上内容,设计适合社会舆论模拟的实体类型和关系类型。
**必须遵守的规则**:
1. 必须正好输出10个实体类型
2. 最后2个必须是兜底类型:Person(个人兜底)和 Organization(组织兜底)
3. 前8个是根据文本内容设计的具体类型
4. 所有实体类型必须是现实中可以发声的主体,不能是抽象概念
5. 属性名不能使用 name、uuid、group_id 等保留字,用 full_name、org_name 等替代
"""
return message
def _build_document_context(self, document_texts: List[str]) -> str:
"""构建用于本体分析的文档上下文,长文本按全局分块抽样而不是只截取开头。"""
combined_text = "\n\n---\n\n".join(document_texts)
original_length = len(combined_text)
if original_length <= self.MAX_TEXT_LENGTH_FOR_LLM:
return combined_text
chunks = self._collect_document_chunks(document_texts)
if not chunks:
return ""
selected_chunks = self._select_representative_chunks(chunks)
excerpt_budget = self._calculate_excerpt_budget(len(selected_chunks))
context = self._render_chunked_context(
selected_chunks=selected_chunks,
original_length=original_length,
total_chunks=len(chunks),
excerpt_limit=excerpt_budget,
)
while len(context) > self.MAX_TEXT_LENGTH_FOR_LLM and excerpt_budget > self.MIN_LONG_TEXT_EXCERPT:
excerpt_budget = max(self.MIN_LONG_TEXT_EXCERPT, int(excerpt_budget * 0.85))
context = self._render_chunked_context(
selected_chunks=selected_chunks,
original_length=original_length,
total_chunks=len(chunks),
excerpt_limit=excerpt_budget,
)
if len(context) > self.MAX_TEXT_LENGTH_FOR_LLM:
marker = "\n\n...(分块上下文已压缩到本体分析长度限制内)..."
context = context[:self.MAX_TEXT_LENGTH_FOR_LLM - len(marker)] + marker
return context
def _collect_document_chunks(self, document_texts: List[str]) -> List[Dict[str, Any]]:
"""按文档收集分块,保留文档和分块编号方便提示词定位。"""
all_chunks: List[Dict[str, Any]] = []
for doc_index, text in enumerate(document_texts, 1):
doc_chunks = split_text_into_chunks(
text,
chunk_size=self.LONG_TEXT_CHUNK_SIZE,
overlap=self.LONG_TEXT_CHUNK_OVERLAP,
)
total_doc_chunks = len(doc_chunks)
for chunk_index, chunk in enumerate(doc_chunks, 1):
all_chunks.append({
"document_index": doc_index,
"chunk_index": chunk_index,
"total_document_chunks": total_doc_chunks,
"text": chunk,
})
return all_chunks
def _select_representative_chunks(self, chunks: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""从全部分块中等距抽样,覆盖长文开头、中段和结尾。"""
if len(chunks) <= self.MAX_LONG_TEXT_CHUNKS:
return chunks
if self.MAX_LONG_TEXT_CHUNKS <= 1:
return [chunks[0]]
last_index = len(chunks) - 1
selected_indexes = {
round(i * last_index / (self.MAX_LONG_TEXT_CHUNKS - 1))
for i in range(self.MAX_LONG_TEXT_CHUNKS)
}
return [chunks[i] for i in sorted(selected_indexes)]
def _calculate_excerpt_budget(self, selected_count: int) -> int:
"""根据选中的分块数量为每块分配字符预算。"""
header_budget = 600
chunk_header_budget = 120 * selected_count
available = max(
self.MIN_LONG_TEXT_EXCERPT * selected_count,
self.MAX_TEXT_LENGTH_FOR_LLM - header_budget - chunk_header_budget,
)
return max(self.MIN_LONG_TEXT_EXCERPT, available // max(selected_count, 1))
def _render_chunked_context(
self,
selected_chunks: List[Dict[str, Any]],
original_length: int,
total_chunks: int,
excerpt_limit: int,
) -> str:
"""渲染长文本分块上下文。"""
lines = [
(
f"【长文本自动分块摘要】原文共{original_length}字,"
f"已分为{total_chunks}个文本块用于全局覆盖分析。"
),
(
f"以下展示其中{len(selected_chunks)}个代表性文本块的摘录,"
"覆盖开头、中段和结尾;请基于这些跨全文线索设计本体,不要只依赖第一段内容。"
),
]
for chunk in selected_chunks:
excerpt = self._excerpt_text(chunk["text"], excerpt_limit)
lines.append(
"\n".join([
(
f"--- 文档 {chunk['document_index']} / "
f"分块 {chunk['chunk_index']}/{chunk['total_document_chunks']} ---"
),
excerpt,
])
)
return "\n\n".join(lines)
@staticmethod
def _excerpt_text(text: str, char_limit: int) -> str:
"""长分块保留首尾,避免每个分块内部再次变成只看开头。"""
text = text.strip()
if len(text) <= char_limit:
return text
marker = "\n...(本分块中间内容省略)...\n"
if char_limit <= len(marker) + 20:
return text[:char_limit]
remaining = char_limit - len(marker)
head_len = remaining // 2
tail_len = remaining - head_len
return f"{text[:head_len].rstrip()}{marker}{text[-tail_len:].lstrip()}"
def _validate_and_process(self, result: Dict[str, Any]) -> Dict[str, Any]:
"""验证和后处理结果"""
# 确保必要字段存在
if "entity_types" not in result:
result["entity_types"] = []
if "edge_types" not in result:
result["edge_types"] = []
if "analysis_summary" not in result:
result["analysis_summary"] = ""
# 验证实体类型
# 记录原始名称到 PascalCase 的映射,用于后续修正 edge 的 source_targets 引用
entity_name_map = {}
for entity in result["entity_types"]:
# 强制将 entity name 转为 PascalCase(Zep API 要求)
if "name" in entity:
original_name = entity["name"]
entity["name"] = _to_pascal_case(original_name)
if entity["name"] != original_name:
logger.warning(f"Entity type name '{original_name}' auto-converted to '{entity['name']}'")
entity_name_map[original_name] = entity["name"]
if "attributes" not in entity:
entity["attributes"] = []
if "examples" not in entity:
entity["examples"] = []
# 确保description不超过100字符
if len(entity.get("description", "")) > 100:
entity["description"] = entity["description"][:97] + "..."
# 验证关系类型
for edge in result["edge_types"]:
# 强制将 edge name 转为 SCREAMING_SNAKE_CASE(Zep API 要求)
if "name" in edge:
original_name = edge["name"]
edge["name"] = original_name.upper()
if edge["name"] != original_name:
logger.warning(f"Edge type name '{original_name}' auto-converted to '{edge['name']}'")
# 修正 source_targets 中的实体名称引用,与转换后的 PascalCase 保持一致
for st in edge.get("source_targets", []):
if st.get("source") in entity_name_map:
st["source"] = entity_name_map[st["source"]]
if st.get("target") in entity_name_map:
st["target"] = entity_name_map[st["target"]]
if "source_targets" not in edge:
edge["source_targets"] = []
if "attributes" not in edge:
edge["attributes"] = []
if len(edge.get("description", "")) > 100:
edge["description"] = edge["description"][:97] + "..."
# Zep API 限制:最多 10 个自定义实体类型,最多 10 个自定义边类型
MAX_ENTITY_TYPES = 10
MAX_EDGE_TYPES = 10
# 去重:按 name 去重,保留首次出现的
seen_names = set()
deduped = []
for entity in result["entity_types"]:
name = entity.get("name", "")
if name and name not in seen_names:
seen_names.add(name)
deduped.append(entity)
elif name in seen_names:
logger.warning(f"Duplicate entity type '{name}' removed during validation")
result["entity_types"] = deduped
# 兜底类型定义
person_fallback = {
"name": "Person",
"description": "Any individual person not fitting other specific person types.",
"attributes": [
{"name": "full_name", "type": "text", "description": "Full name of the person"},
{"name": "role", "type": "text", "description": "Role or occupation"}
],
"examples": ["ordinary citizen", "anonymous netizen"]
}
organization_fallback = {
"name": "Organization",
"description": "Any organization not fitting other specific organization types.",
"attributes": [
{"name": "org_name", "type": "text", "description": "Name of the organization"},
{"name": "org_type", "type": "text", "description": "Type of organization"}
],
"examples": ["small business", "community group"]
}
# 检查是否已有兜底类型
entity_names = {e["name"] for e in result["entity_types"]}
has_person = "Person" in entity_names
has_organization = "Organization" in entity_names
# 需要添加的兜底类型
fallbacks_to_add = []
if not has_person:
fallbacks_to_add.append(person_fallback)
if not has_organization:
fallbacks_to_add.append(organization_fallback)
if fallbacks_to_add:
current_count = len(result["entity_types"])
needed_slots = len(fallbacks_to_add)
# 如果添加后会超过 10 个,需要移除一些现有类型
if current_count + needed_slots > MAX_ENTITY_TYPES:
# 计算需要移除多少个
to_remove = current_count + needed_slots - MAX_ENTITY_TYPES
# 从末尾移除(保留前面更重要的具体类型)
result["entity_types"] = result["entity_types"][:-to_remove]
# 添加兜底类型
result["entity_types"].extend(fallbacks_to_add)
# 最终确保不超过限制(防御性编程)
if len(result["entity_types"]) > MAX_ENTITY_TYPES:
result["entity_types"] = result["entity_types"][:MAX_ENTITY_TYPES]
if len(result["edge_types"]) > MAX_EDGE_TYPES:
result["edge_types"] = result["edge_types"][:MAX_EDGE_TYPES]
return result
def generate_python_code(self, ontology: Dict[str, Any]) -> str:
"""
将本体定义转换为Python代码(类似ontology.py)
Args:
ontology: 本体定义
Returns:
Python代码字符串
"""
code_lines = [
'"""',
'自定义实体类型定义',
'由MiroFish自动生成,用于社会舆论模拟',
'"""',
'',
'from pydantic import Field',
'from zep_cloud.external_clients.ontology import EntityModel, EntityText, EdgeModel',
'',
'',
'# ============== 实体类型定义 ==============',
'',
]
# 生成实体类型
for entity in ontology.get("entity_types", []):
name = entity["name"]
desc = entity.get("description", f"A {name} entity.")
code_lines.append(f'class {name}(EntityModel):')
code_lines.append(f' """{desc}"""')
attrs = entity.get("attributes", [])
if attrs:
for attr in attrs:
attr_name = attr["name"]
attr_desc = attr.get("description", attr_name)
code_lines.append(f' {attr_name}: EntityText = Field(')
code_lines.append(f' description="{attr_desc}",')
code_lines.append(f' default=None')
code_lines.append(f' )')
else:
code_lines.append(' pass')
code_lines.append('')
code_lines.append('')
code_lines.append('# ============== 关系类型定义 ==============')
code_lines.append('')
# 生成关系类型
for edge in ontology.get("edge_types", []):
name = edge["name"]
# 转换为PascalCase类名
class_name = ''.join(word.capitalize() for word in name.split('_'))
desc = edge.get("description", f"A {name} relationship.")
code_lines.append(f'class {class_name}(EdgeModel):')
code_lines.append(f' """{desc}"""')
attrs = edge.get("attributes", [])
if attrs:
for attr in attrs:
attr_name = attr["name"]
attr_desc = attr.get("description", attr_name)
code_lines.append(f' {attr_name}: EntityText = Field(')
code_lines.append(f' description="{attr_desc}",')
code_lines.append(f' default=None')
code_lines.append(f' )')
else:
code_lines.append(' pass')
code_lines.append('')
code_lines.append('')
# 生成类型字典
code_lines.append('# ============== 类型配置 ==============')
code_lines.append('')
code_lines.append('ENTITY_TYPES = {')
for entity in ontology.get("entity_types", []):
name = entity["name"]
code_lines.append(f' "{name}": {name},')
code_lines.append('}')
code_lines.append('')
code_lines.append('EDGE_TYPES = {')
for edge in ontology.get("edge_types", []):
name = edge["name"]
class_name = ''.join(word.capitalize() for word in name.split('_'))
code_lines.append(f' "{name}": {class_name},')
code_lines.append('}')
code_lines.append('')
# 生成边的source_targets映射
code_lines.append('EDGE_SOURCE_TARGETS = {')
for edge in ontology.get("edge_types", []):
name = edge["name"]
source_targets = edge.get("source_targets", [])
if source_targets:
st_list = ', '.join([
f'{{"source": "{st.get("source", "Entity")}", "target": "{st.get("target", "Entity")}"}}'
for st in source_targets
])
code_lines.append(f' "{name}": [{st_list}],')
code_lines.append('}')
return '\n'.join(code_lines)