Ray1ee01 commited on
Commit
9eba547
·
verified ·
1 Parent(s): a389b7a

Upload folder using huggingface_hub

Browse files
modules/title_generator/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """
2
+ Title Generator package initialization.
3
+ """
modules/title_generator/create_index.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import os
3
+ import sys
4
+ import json
5
+ import logging
6
+ from tqdm import tqdm
7
+
8
+ # 添加项目根目录到Python路径
9
+ root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
10
+ sys.path.append(root_dir)
11
+
12
+ # 然后使用相对于项目根目录的导入
13
+ from modules.title_generator.title_generator import RagTitleGenerator
14
+
15
+ # 配置日志
16
+ logging.basicConfig(
17
+ level=logging.INFO,
18
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
19
+ )
20
+ logger = logging.getLogger("BuildTitleIndex")
21
+
22
+ def process(data,
23
+ index_path: str='faiss_infographics.index',
24
+ data_path: str='infographics_data.npy',
25
+ embed_model_path: str='',
26
+ force: bool=False):
27
+ try:
28
+ # 检查索引文件是否存在
29
+ if os.path.exists(index_path) and not force:
30
+ logger.info(f"索引文件 {index_path} 已存在,跳过创建。使用 --force 参数强制重建。")
31
+ return True
32
+
33
+ logger.info("Initializing RagTitleGenerator...")
34
+ generator = RagTitleGenerator(
35
+ index_path=index_path,
36
+ data_path=data_path,
37
+ embed_model_path=embed_model_path
38
+ )
39
+
40
+ logger.info("Building FAISS index...")
41
+ generator.build_faiss_index(data)
42
+ logger.info("Index built and saved successfully.")
43
+ return True
44
+ except Exception as e:
45
+ logger.error(f"构建索引失败: {str(e)}")
46
+ return False
47
+
48
+ def main(force: bool=False):
49
+ parser = argparse.ArgumentParser(description="Build FAISS index for title generation")
50
+ parser.add_argument('--data', type=str, required=True, help='Path to training data JSON file')
51
+ parser.add_argument('--index_path', type=str, default='faiss_infographics.index', help='Path to store FAISS index')
52
+ parser.add_argument('--data_path', type=str, default='infographics_data.npy', help='Path to store embedding + title data')
53
+ parser.add_argument('--embed_model_path', type=str, default='', help='Path to sentence embedding model (optional)')
54
+ parser.add_argument('--force', action='store_true', help='Force rebuild even if index exists')
55
+
56
+ args = parser.parse_args()
57
+
58
+ process(args.data, args.index_path, args.data_path, args.embed_model_path, force or args.force)
59
+
60
+ if __name__ == "__main__":
61
+ try:
62
+ main()
63
+ except Exception as e:
64
+ logger.error(f"程序执行失败: {str(e)}")
65
+ sys.exit(1)
modules/title_generator/title_generator.py ADDED
@@ -0,0 +1,459 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import faiss
4
+ import numpy as np
5
+ from tqdm import tqdm
6
+ import argparse
7
+ import logging
8
+ from typing import Any, Dict, List, Tuple, Union
9
+ from openai import OpenAI
10
+ from utils.model_loader import ModelLoader
11
+
12
+ # 配置日志
13
+ logging.basicConfig(
14
+ level=logging.INFO,
15
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
16
+ )
17
+ logger = logging.getLogger("RagTitleGenerator")
18
+
19
+ _GENERATOR_CACHE = {}
20
+
21
+
22
+ def _has_required_titles(data: Dict[str, Any]) -> bool:
23
+ titles = data.get("titles")
24
+ return (
25
+ isinstance(titles, dict)
26
+ and bool(str(titles.get("main_title", "")).strip())
27
+ and bool(str(titles.get("sub_title", "")).strip())
28
+ )
29
+
30
+
31
+ def _get_generator(
32
+ index_path: str,
33
+ data_path: str,
34
+ embed_model_path: str,
35
+ api_key: str,
36
+ base_url: str
37
+ ) -> "RagTitleGenerator":
38
+ key = (index_path, data_path, embed_model_path, api_key, base_url)
39
+ generator = _GENERATOR_CACHE.get(key)
40
+ if generator is None:
41
+ generator = RagTitleGenerator(
42
+ index_path=index_path,
43
+ data_path=data_path,
44
+ embed_model_path=embed_model_path,
45
+ api_key=api_key,
46
+ base_url=base_url
47
+ )
48
+ _GENERATOR_CACHE[key] = generator
49
+ return generator
50
+
51
+
52
+ class RagTitleGenerator:
53
+ """
54
+ RagTitleGenerator is a class that handles both:
55
+ 1. Building/maintaining the FAISS index from a given dataset.
56
+ 2. Generating infographic titles and descriptions for a single data item using RAG.
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ index_path: str = "faiss_infographics.index",
62
+ data_path: str = "infographics_data.npy",
63
+ embed_model_path="",
64
+ api_key: str="",
65
+ base_url: str=""
66
+ ) -> None:
67
+ """
68
+ Initialize the RagTitleGenerator. It attempts to load any existing FAISS index
69
+ and corresponding training data from disk. If no index file is found, the index
70
+ will be None until build_faiss_index is called.
71
+
72
+ Args:
73
+ index_path (str, optional): Path where the FAISS index file is or will be stored.
74
+ data_path (str, optional): Path where the training data embeddings are stored.
75
+ embed_model_path: Custom embedding model. If None, defaults to SentenceTransformer("all-MiniLM-L6-v2").
76
+ """
77
+ self.index_path = index_path
78
+ self.data_path = data_path
79
+ if embed_model_path:
80
+ self.embed_model = ModelLoader.get_model(embed_model_path)
81
+ else:
82
+ print("fuck")
83
+ #else:
84
+ # self.embed_model = SentenceTransformer("all-MiniLM-L6-v2")
85
+
86
+ self.index = None
87
+ self.training_data = [] # List of tuples: (input_text, title, description)
88
+
89
+ print(api_key, base_url)
90
+ self.client = OpenAI(
91
+ api_key=api_key,
92
+ base_url=base_url
93
+ # "https://aihubmix.com/v1"
94
+ )
95
+
96
+ # Try loading an existing FAISS index and data
97
+ if os.path.exists(self.index_path) and os.path.exists(self.data_path):
98
+ print("Load existing FAISS index from disk.")
99
+ self.index = faiss.read_index(self.index_path)
100
+ with open(self.data_path, "rb") as f:
101
+ self.training_data = np.load(f, allow_pickle=True).tolist()
102
+ else:
103
+ print("No existing FAISS index found; you can build a new one via build_faiss_index().")
104
+
105
+ def build_faiss_index(self, data_json_path):
106
+ """Build a FAISS index from a JSON dataset of chart data."""
107
+ try:
108
+ if not os.path.exists(data_json_path):
109
+ raise ValueError(f"Provided data_json_path {data_json_path} does not exist.")
110
+
111
+ # Remove existing index
112
+ self.clear_faiss_data()
113
+
114
+ # Load the entire dataset from JSON
115
+ with open(data_json_path, "r", encoding="utf-8") as f:
116
+ dataset = json.load(f)
117
+ print("dataset", type(dataset))
118
+ # Build the FAISS index from scratch
119
+ if isinstance(dataset, dict):
120
+ for name, details in tqdm(dataset.items(), desc="Building new FAISS index"):
121
+ processed_text = self.process_single_data(details)
122
+ title = details.get("metadata", {}).get("title", "")
123
+ description = details.get("metadata", {}).get("description", "")
124
+ self.add_training_data(processed_text, title, description)
125
+ else:
126
+ raise ValueError("Dataset must be a dictionary")
127
+
128
+ except Exception as e:
129
+ logger.error(f"处理记录时出错: {str(e)}")
130
+ raise
131
+
132
+ def clear_faiss_data(self) -> None:
133
+ """Remove existing FAISS index and training data from disk."""
134
+ if os.path.exists(self.index_path):
135
+ os.remove(self.index_path)
136
+ if os.path.exists(self.data_path):
137
+ os.remove(self.data_path)
138
+ self.index = None
139
+ self.training_data = []
140
+ print("Original FAISS has been cleared.")
141
+
142
+ def process_single_data(
143
+ self,
144
+ data: Dict[str, Any]
145
+ ) -> str:
146
+ """
147
+ Convert a single data dictionary into a textual representation for retrieval and generation.
148
+ This includes metadata, chart_type, datafacts, etc.
149
+
150
+ Args:
151
+ data (Dict[str, Any]): A dictionary containing chart data, metadata, etc.
152
+
153
+ Returns:
154
+ str: A concatenated text representation of the data.
155
+ """
156
+ metadata = data.get("metadata", {})
157
+ chart_type = data.get("chart_type", [])
158
+ datafacts = data.get("datafacts", [])
159
+ data_columns = data["data"].get("columns", [])
160
+ chart_data = data["data"].get("data", [])
161
+
162
+ main_insight = metadata.get("main_insight", "")
163
+
164
+ # Convert chart type to text
165
+ chart_type_text = "Chart Type: " + ", ".join(chart_type) + "\n" if chart_type else ""
166
+
167
+ # Convert datafacts to text
168
+ datafacts_text = "Data Facts:\n"
169
+ if datafacts:
170
+ for fact in datafacts:
171
+ annotation = fact.get("annotation", "")
172
+ if annotation:
173
+ datafacts_text += f"- {annotation}\n"
174
+ else:
175
+ datafacts_text = ""
176
+
177
+ # Main insight
178
+ main_insight_text = f"Main Insight: {main_insight}\n" if main_insight else ""
179
+
180
+ # Data columns
181
+ column_text = "Columns: "
182
+ if data_columns:
183
+ column_names = [col.get("name", "") for col in data_columns]
184
+ # column_names = [f"{col.get('name', '')} ({col.get('description', '')})" for col in data_columns]
185
+ column_text += ", ".join(column_names) + "\n"
186
+ else:
187
+ column_text = ""
188
+
189
+ # Data sample (limit to first few rows)
190
+ data_text = "Data Sample:\n"
191
+ for row in chart_data:
192
+ row_text = ", ".join([f"{k}: {v}" for k, v in row.items()])
193
+ data_text += f"{row_text}\n"
194
+
195
+ final_text = (
196
+ f"{chart_type_text}\n"
197
+ f"{datafacts_text}\n"
198
+ f"{main_insight_text}\n"
199
+ f"{column_text}\n"
200
+ f"{data_text}"
201
+ )
202
+ return final_text
203
+
204
+ def add_training_data(
205
+ self,
206
+ input_text: str,
207
+ title: str,
208
+ description: str
209
+ ) -> None:
210
+ """
211
+ Add a single training sample into the in-memory list and FAISS index.
212
+ After adding, it writes the updated index and data to disk.
213
+
214
+ Args:
215
+ input_text (str): Concatenated text derived from chart data.
216
+ title (str): Ground truth or known title from the data's metadata.
217
+ description (str): Ground truth or known description from the data's metadata.
218
+ """
219
+ self.training_data.append((input_text, title, description))
220
+
221
+ embedding = self.embed_model.encode([input_text])
222
+
223
+ if self.index is None:
224
+ dim = embedding.shape[1]
225
+ self.index = faiss.IndexFlatL2(dim)
226
+
227
+ self.index.add(np.array(embedding))
228
+
229
+ faiss.write_index(self.index, self.index_path)
230
+
231
+ with open(self.data_path, "wb") as f:
232
+ np.save(f, np.array(self.training_data, dtype=object))
233
+
234
+ def retrieve_similar(
235
+ self,
236
+ new_input: str,
237
+ topk: int = 7
238
+ ) -> List[Tuple[str, str, str]]:
239
+ """
240
+ Retrieve the top-k most similar training samples from the FAISS index.
241
+
242
+ Args:
243
+ new_input (str): The new text query to encode.
244
+ topk (int, optional): Number of similar samples to retrieve.
245
+
246
+ Returns:
247
+ List[Tuple[str, str, str]]: A list of (input_text, title, description).
248
+ """
249
+ if self.index is None or len(self.training_data) == 0:
250
+ return []
251
+
252
+ new_embedding = self.embed_model.encode([new_input])
253
+ topk = min(topk, len(self.training_data))
254
+ _, I = self.index.search(np.array(new_embedding), k=topk)
255
+
256
+ retrieved_list = []
257
+ for idx in I[0]:
258
+ data = self.training_data[idx]
259
+ retrieved_list.append((data[0], data[1], data[2]))
260
+
261
+ return retrieved_list
262
+
263
+ def generate_title_description(
264
+ self,
265
+ data: Dict[str, Any],
266
+ topk: int = 7
267
+ ) -> Tuple[str, str]:
268
+ """
269
+ Generate a title and subtitle for a single data dictionary using RAG.
270
+
271
+ Args:
272
+ data (Dict[str, Any]): The data dictionary containing metadata, chart_type, datafacts, etc.
273
+ topk (int, optional): Number of similar examples to retrieve for prompt augmentation.
274
+
275
+ Returns:
276
+ Tuple[str, str]: (generated_title, generated_description)
277
+ """
278
+ processed_text = self.process_single_data(data)
279
+
280
+ retrieved_examples = self.retrieve_similar(processed_text, topk=topk) if topk > 0 else []
281
+
282
+ max_title_words = 8
283
+ max_description_words = 13
284
+ for _, rt, rd in retrieved_examples:
285
+ rt = rt or ""
286
+ rd = rd or ""
287
+ max_title_words = max(max_title_words, len(rt.split()))
288
+ max_description_words = max(max_description_words, len(rd.split()))
289
+
290
+ example_text = ""
291
+ if retrieved_examples:
292
+ example_text += "Here are some similar examples:\n"
293
+ for i, (r_data, rt, rd) in enumerate(retrieved_examples, start=1):
294
+ example_text += (
295
+ f"\n[Similar Example {i}]\n"
296
+ f"Input Data:\n{r_data}\n"
297
+ f"Title: {rt if rt else ''}\n"
298
+ f"Description: {rd if rd else ''}\n"
299
+ )
300
+ title_prompt = (
301
+ f"{example_text}\n"
302
+ "Based on the above examples (if any), please generate a clear and concise TITLE for the following data.\n"
303
+ f"{processed_text}\n\n"
304
+ "Important instructions:\n"
305
+ "1. The title should focus on the most significant feature of the data. "
306
+ "You can choose one or more key insights from the Data Facts that best "
307
+ "illustrate the issue, or identify the most notable feature yourself.\n"
308
+ # "2. The title should focus solely on what the data is about, without analyzing specific "
309
+ # "data characteristics, trends, distributions, or comparisons.\n"
310
+ f"2. The title should be strictly under {max_title_words} words.\n"
311
+ "3. Use exact terminology from the data sources.\n"
312
+ "4. Do NOT use these verbs: show, reveal, illustrate, analyze.\n"
313
+ "5. ONLY return the title as a string, no extra text."
314
+ )
315
+
316
+ response_title = self.client.chat.completions.create(
317
+ model="gpt-4o-mini",
318
+ messages=[
319
+ {"role": "system", "content": "You are an AI assistant that generates infographic titles."},
320
+ {"role": "user", "content": title_prompt}
321
+ ]
322
+ )
323
+ generated_title = response_title.choices[0].message.content
324
+ generated_title = generated_title.strip() if generated_title else ""
325
+
326
+ description_prompt = (
327
+ f"{example_text}\n"
328
+ "Based on the above examples (if any), please generate a precise DESCRIPTION for the following data.\n"
329
+ f"{processed_text}\n\n"
330
+ "Important instructions:\n"
331
+ "1. Do NOT describe statistical properties (e.g., highest/lowest values, changes over time, "
332
+ "ratios, percentages). Simply summarize what the dataset reports.\n"
333
+ "2. Use one of the following structured templates where applicable (choose the highest-priority one that fits):\n"
334
+ " - Share/Percentage of [group] (who [action/characteristic]) (by [region/timeframe] (in [units])).\n"
335
+ " - Number/Total/Amount of [entity] (in [region/timeframe]) (, measured in [units]).\n"
336
+ " - Top/Leading N [entities] by [indicator] (, in [timeframe/region]).\n"
337
+ " - [Indicator] for [group/topic] (in [region/timeframe]) (, measured in [units]).\n"
338
+ f"3. The description should be strictly under {max_description_words} words.\n"
339
+ "4. Use exact terminology from the data sources.\n"
340
+ "5. Do NOT use these verbs: show, reveal, illustrate, analyze.\n"
341
+ "6. ONLY return the description as a string, no extra text."
342
+ )
343
+
344
+ response_description = self.client.chat.completions.create(
345
+ model="gpt-4o-mini",
346
+ messages=[
347
+ {"role": "system", "content": "You are an AI assistant that generates infographic descriptions."},
348
+ {"role": "user", "content": description_prompt}
349
+ ]
350
+ )
351
+ generated_description = response_description.choices[0].message.content
352
+ generated_description = generated_description.strip() if generated_description else ""
353
+
354
+ return generated_title, generated_description
355
+
356
+ def process(
357
+ input: str = None,
358
+ output: str = None,
359
+ input_data: Dict = None,
360
+ index_path: str = "faiss_infographics.index",
361
+ data_path: str = "infographics_data.npy",
362
+ topk: int = 7,
363
+ embed_model_path = "",
364
+ api_key: str="",
365
+ base_url: str=""
366
+ ) -> Union[bool, Dict]:
367
+ """
368
+ Process function for generating the title and subtitle for a single data object.
369
+
370
+ Args:
371
+ input (str, optional): Path to the input JSON file with a single data object.
372
+ output (str, optional): Path to the output JSON file.
373
+ input_data (Dict, optional): A single data dictionary (alternative to file input).
374
+ index_path (str, optional): Path to the FAISS index file.
375
+ data_path (str, optional): Path to the training data embeddings file.
376
+ topk (int, optional): Number of similar examples to retrieve.
377
+
378
+ Returns:
379
+ Union[bool, Dict]:
380
+ - If output is provided, returns True/False indicating success/failure.
381
+ - Otherwise, returns the updated data dictionary with generated titles.
382
+ """
383
+ try:
384
+ # Load the single data object
385
+ if input_data is None:
386
+ if input is None:
387
+ return False
388
+ with open(input, 'r', encoding='utf-8') as f:
389
+ data = json.load(f)
390
+ else:
391
+ data = input_data
392
+ if output is None:
393
+ output = input
394
+
395
+ if _has_required_titles(data):
396
+ if output:
397
+ with open(output, 'w', encoding='utf-8') as f:
398
+ json.dump(data, f, ensure_ascii=False, indent=2)
399
+ return True
400
+
401
+ generator = _get_generator(
402
+ index_path=index_path,
403
+ data_path=data_path,
404
+ embed_model_path=embed_model_path,
405
+ api_key=api_key,
406
+ base_url=base_url
407
+ )
408
+
409
+ main_title, sub_title = generator.generate_title_description(data, topk=topk)
410
+
411
+ if "titles" not in data:
412
+ data["titles"] = {}
413
+ data["titles"]["main_title"] = main_title
414
+ data["titles"]["sub_title"] = sub_title
415
+
416
+ if output:
417
+ with open(output, 'w', encoding='utf-8') as f:
418
+ json.dump(data, f, ensure_ascii=False, indent=2)
419
+ return True
420
+
421
+ return True
422
+
423
+ except Exception as e:
424
+ print(f"Error in title generation: {str(e)}")
425
+ return False
426
+
427
+
428
+ def main():
429
+ parser = argparse.ArgumentParser(description='Title generator for chart data')
430
+ parser.add_argument('--input', type=str, required=True, help='Input JSON file path (single data object).')
431
+ parser.add_argument('--output', type=str, required=True, help='Output JSON file path.')
432
+ parser.add_argument('--index_path', type=str, default='faiss_infographics.index', help='FAISS index file path.')
433
+ parser.add_argument('--data_path', type=str, default='infographics_data.npy', help='Training data path.')
434
+ parser.add_argument('--topk', type=int, default=3, help='Number of similar examples to retrieve.')
435
+ parser.add_argument('--embed_model_path', type=str, default='', help='Sentence transformer path')
436
+ parser.add_argument('--api_key', type=str, default='', help='API key for LLM.')
437
+ parser.add_argument('--base_url', type=str, default='', help='Base URL for LLM.')
438
+
439
+ args = parser.parse_args()
440
+
441
+ success = process(
442
+ input=args.input,
443
+ output=args.output,
444
+ index_path=args.index_path,
445
+ data_path=args.data_path,
446
+ topk=args.topk,
447
+ embed_model_path=args.embed_model_path,
448
+ api_key=args.api_key,
449
+ base_url=args.base_url
450
+ )
451
+
452
+ if success:
453
+ print("Title generation completed successfully.")
454
+ else:
455
+ print("Title generation failed.")
456
+
457
+
458
+ if __name__ == "__main__":
459
+ main()