docs-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
evaluate_coverage.py ADDED
@@ -0,0 +1,362 @@
1
+ # evaluate_coverage.py (Versão com avaliação de cobertura por similaridade de frases e CLI)
2
+
3
+ import json
4
+ import csv
5
+ import os
6
+ import google.generativeai as genai
7
+ from dotenv import load_dotenv
8
+ import time
9
+ import numpy as np # Para cálculo de similaridade de cosseno
10
+ import re # Para manipulação de texto e divisão de frases
11
+ import argparse # Adicionado para parsing de argumentos CLI
12
+ import sys # Adicionado para sys.exit
13
+
14
+ # Carrega as variáveis de ambiente do arquivo .env
15
+ load_dotenv()
16
+
17
+ # --- Configuração da API do Google Gemini (igual ao generate_embeddings.py) ---
18
+ GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY")
19
+ if not GOOGLE_API_KEY:
20
+ raise ValueError("A variável de ambiente GOOGLE_API_KEY não está configurada.")
21
+ # genai.configure(api_key=GOOGLE_API_KEY) # Removido porque não existe em google.generativeai
22
+ os.environ["GOOGLE_API_KEY"] = GOOGLE_API_KEY # Garante que a variável de ambiente está definida
23
+ EMBEDDING_MODEL = "models/embedding-001"
24
+
25
+ # Limite de caracteres para o embedding, para evitar exceder o limite de tokens da API
26
+ EMBEDDING_TEXT_MAX_LENGTH = 1024
27
+
28
+ # --- Variáveis para controle de Rate Limiting (igual ao generate_embeddings.py) ---
29
+ REQUEST_LIMIT_PER_MINUTE = 150
30
+ request_count = 0
31
+ last_request_time = time.time()
32
+
33
+ # Funções auxiliares (copiadas de generate_embeddings.py e adaptadas)
34
+
35
+ def clean_text_for_embedding(text):
36
+ """
37
+ Remove caracteres especiais e formatação markdown para texto que será EMBEDDADO.
38
+ Esta função foi aprimorada para lidar com mais casos de Markdown.
39
+ """
40
+ if not isinstance(text, str):
41
+ return ""
42
+
43
+ # Remove links markdown (e.g., [texto](link))
44
+ text = re.sub(r'\[.*?\]\(.*?\)', '', text)
45
+ # Remove bold/italic (**, __, *, _)
46
+ text = re.sub(r'\*\*|__|\*|_', '', text)
47
+ # Remove cabeçalhos (#, ##, ### etc.)
48
+ text = re.sub(r'#+\s*', '', text)
49
+ # Remove blocos de código (``` ou `)
50
+ text = re.sub(r'```.*?```', '', text, flags=re.DOTALL) # Para blocos multilinhas
51
+ text = re.sub(r'`[^`]*`', '', text) # Para blocos de uma linha
52
+ # Remove blockquotes (>)
53
+ text = re.sub(r'^\s*>\s*', '', text, flags=re.MULTILINE)
54
+ # Remove linhas de lista (- + *)
55
+ text = re.sub(r'^\s*[-+*]\s*', '', text, flags=re.MULTILINE)
56
+ # Remove linhas horizontais (---, ***, ___)
57
+ text = re.sub(r'^-{3,}|^\*{3,}|^__{3,}', '', text, flags=re.MULTILINE)
58
+ # Remove múltiplos espaços e quebras de linha para um único espaço
59
+ text = re.sub(r'\s+', ' ', text).strip()
60
+ # Substitui múltiplas quebras de linha por um único espaço (se houver alguma que restou)
61
+ text = re.sub(r'\n+', ' ', text).strip()
62
+ return text
63
+
64
+ def generate_embedding_with_retry(text_content):
65
+ """
66
+ Gera um embedding para o conteúdo de texto, com mecanismo de retry e rate limiting.
67
+ """
68
+ global request_count, last_request_time
69
+
70
+ current_time = time.time()
71
+ elapsed_time = current_time - last_request_time
72
+
73
+ if elapsed_time < 60 and request_count >= REQUEST_LIMIT_PER_MINUTE:
74
+ sleep_duration = 60 - elapsed_time
75
+ print(f" Atingido limite de requisições por minuto. Aguardando {sleep_duration:.2f} segundos...")
76
+ time.sleep(sleep_duration)
77
+ request_count = 0
78
+ last_request_time = time.time()
79
+ elif elapsed_time >= 60:
80
+ request_count = 0
81
+ last_request_time = time.time()
82
+
83
+ request_count += 1
84
+
85
+ retries = 3
86
+ for attempt in range(retries):
87
+ try:
88
+ # É importante limpar o texto antes de gerar o embedding, assim como no generate_embeddings.py
89
+ cleaned_text = clean_text_for_embedding(text_content)
90
+ if not cleaned_text: # Se o texto ficar vazio após a limpeza, não tentar gerar embedding
91
+ return None
92
+
93
+ if len(cleaned_text) > EMBEDDING_TEXT_MAX_LENGTH:
94
+ cleaned_text = cleaned_text[:EMBEDDING_TEXT_MAX_LENGTH]
95
+ # print(f" Truncando texto para embedding para {EMBEDDING_TEXT_MAX_LENGTH} caracteres.")
96
+
97
+ response = genai.generate_embeddings(model=EMBEDDING_MODEL, content=cleaned_text) # type: ignore
98
+ return response['embeddings'][0]['values']
99
+ except Exception as e:
100
+ print(f"Erro ao gerar embedding (tentativa {attempt+1}/{retries}): {e}")
101
+ if attempt < retries - 1:
102
+ time.sleep(2 ** attempt) # Espera exponencial
103
+ else:
104
+ return None # Retorna None se todas as retries falharem
105
+ return None
106
+
107
+ def cosine_similarity(vecA, vecB):
108
+ """
109
+ Calcula a similaridade de cosseno entre dois vetores.
110
+ """
111
+ # Certifica-se de que os vetores são arrays numpy
112
+ vecA = np.array(vecA)
113
+ vecB = np.array(vecB)
114
+
115
+ # Verifica se os vetores não estão vazios
116
+ if vecA.size == 0 or vecB.size == 0:
117
+ return 0.0
118
+
119
+ dot_product = np.dot(vecA, vecB)
120
+ norm_A = np.linalg.norm(vecA)
121
+ norm_B = np.linalg.norm(vecB)
122
+
123
+ if norm_A == 0 or norm_B == 0:
124
+ return 0.0
125
+ return dot_product / (norm_A * norm_B)
126
+
127
+ def get_relevant_chunks(query_embedding, processed_chunks, top_k=5):
128
+ """
129
+ Encontra os chunks mais relevantes com base na similaridade de cosseno.
130
+ """
131
+ similarities = []
132
+ for chunk_info in processed_chunks:
133
+ chunk_embedding = chunk_info.get('embedding')
134
+ if chunk_embedding and len(chunk_embedding) > 0 and len(query_embedding) == len(chunk_embedding):
135
+ try:
136
+ similarity = cosine_similarity(query_embedding, chunk_embedding)
137
+ similarities.append({'similarity': similarity, 'chunk': chunk_info})
138
+ except Exception as e:
139
+ print(f"Aviso: Erro ao calcular similaridade para chunk '{chunk_info.get('chunk_title', 'N/A')}' do documento '{chunk_info.get('document_title', 'N/A')}': {e}")
140
+ else:
141
+ pass # Silenciar avisos excessivos para embeddings inválidos/incompatíveis
142
+
143
+ similarities.sort(key=lambda x: x['similarity'], reverse=True)
144
+ return similarities[:top_k]
145
+
146
+ # MODIFICADO: Adicionado output_json_path como parâmetro
147
+ def evaluate_coverage(qa_filepath="gartner_filtrado_processed.csv",
148
+ chunks_filepath="processed_chunks_with_embeddings.json",
149
+ top_k_chunks=5,
150
+ output_json_path="evaluation_results.json"): # MODIFICADO: parâmetro para nome do arquivo de saída
151
+ """
152
+ Avalia a cobertura da documentação usando um arquivo CSV de perguntas e respostas ideais.
153
+ A avaliação considera a similaridade de frases da resposta ideal com os chunks relevantes.
154
+ Salva os resultados no caminho especificado por output_json_path.
155
+ """
156
+ if not os.path.exists(qa_filepath):
157
+ print(f"Erro: O arquivo de perguntas e respostas '{qa_filepath}' não foi encontrado.")
158
+ return False
159
+ if not os.path.exists(chunks_filepath):
160
+ print(f"Erro: O arquivo de chunks processados '{chunks_filepath}' não foi encontrado. Execute 'generate_embeddings.py' primeiro.")
161
+ return False
162
+
163
+ print(f"Carregando chunks de '{chunks_filepath}'...")
164
+ try:
165
+ with open(chunks_filepath, 'r', encoding='utf-8') as f:
166
+ processed_chunks = json.load(f)
167
+ except json.JSONDecodeError as e:
168
+ print(f"Erro ao decodificar JSON de '{chunks_filepath}': {e}")
169
+ return False
170
+ except Exception as e:
171
+ print(f"Erro inesperado ao carregar '{chunks_filepath}': {e}")
172
+ return False
173
+
174
+ # Filtrar chunks que não têm embedding válido (se houver algum)
175
+ processed_chunks = [c for c in processed_chunks if c.get('embedding') is not None]
176
+ if not processed_chunks:
177
+ print("Erro: Nenhum chunk com embedding válido encontrado após o carregamento. Verifique o arquivo de chunks com embeddings.")
178
+ return False
179
+
180
+
181
+ print(f"Carregando perguntas e respostas de '{qa_filepath}'...")
182
+ qa_pairs = []
183
+ try:
184
+ with open(qa_filepath, 'r', encoding='utf-8') as f:
185
+ reader = csv.DictReader(f)
186
+ for row in reader:
187
+ # Adaptação para as colunas do seu CSV: 'question' e 'response'
188
+ if 'question' in row and 'response' in row:
189
+ qa_pairs.append({'pergunta': row['question'], 'resposta_ideal': row['response']})
190
+ else:
191
+ print(f"Aviso: Linha ignorada no CSV. Esperava 'question' e 'response': {row}")
192
+ except Exception as e:
193
+ print(f"Erro ao carregar ou ler o arquivo CSV '{qa_filepath}': {e}")
194
+ return False
195
+
196
+ if not qa_pairs:
197
+ print("Atenção: Nenhum par de pergunta-resposta válido encontrado no CSV.")
198
+ return False
199
+
200
+ evaluation_results = []
201
+ total_questions = len(qa_pairs)
202
+ found_in_top_k_count = 0
203
+
204
+ # Limiares para a avaliação de frases
205
+ MIN_SENTENCE_SIMILARITY_THRESHOLD = 0.65 # Similaridade mínima para uma frase ser considerada "coberta"
206
+ MIN_PHRASES_COVERED_PERCENTAGE = 0.70 # % mínima de frases da resposta ideal que devem ser cobertas
207
+
208
+ print(f"\nIniciando avaliação de cobertura para {total_questions} perguntas...")
209
+ print(f"Configuração de avaliação: Considerar 'Encontrada' se {MIN_PHRASES_COVERED_PERCENTAGE*100:.0f}% das frases da resposta ideal tiverem similaridade >= {MIN_SENTENCE_SIMILARITY_THRESHOLD:.2f} com os top {top_k_chunks} chunks.")
210
+
211
+
212
+ for i, qa in enumerate(qa_pairs):
213
+ question = qa['pergunta']
214
+ ideal_answer = qa['resposta_ideal']
215
+
216
+ print(f"\n--- Avaliando Pergunta {i + 1}/{total_questions}: '{question[:100]}...' ---") # Mostra o começo da pergunta
217
+
218
+ # 1. Gerar embedding da pergunta
219
+ query_embedding = generate_embedding_with_retry(question)
220
+ if query_embedding is None:
221
+ print(f" Falha ao gerar embedding para a pergunta. Pulando.")
222
+ evaluation_results.append({
223
+ "pergunta": question,
224
+ "resposta_ideal": ideal_answer,
225
+ "status": "Falha no Embedding da Pergunta",
226
+ "cobertura_detalhes": [],
227
+ "top_k_chunks_relevantes": []
228
+ })
229
+ continue
230
+
231
+ # 2. Encontrar chunks relevantes
232
+ relevant_chunks_with_similarity = get_relevant_chunks(query_embedding, processed_chunks, top_k=top_k_chunks)
233
+
234
+ # Preparar detalhes dos chunks relevantes para o relatório
235
+ top_chunks_report = []
236
+ for item in relevant_chunks_with_similarity:
237
+ chunk = item['chunk']
238
+ top_chunks_report.append({
239
+ "document_title": chunk.get('document_title', 'N/A'),
240
+ "chunk_title": chunk.get('chunk_title', 'N/A'),
241
+ "filepath": chunk.get('document_filepath', 'N/A'),
242
+ "similarity_to_query": f"{item['similarity']:.4f}",
243
+ "content_preview": chunk.get('chunk_content', '')[:200] + "..." if len(chunk.get('chunk_content', '')) > 200 else chunk.get('chunk_content', '')
244
+ })
245
+
246
+ # 3. Avaliar cobertura da resposta ideal pelas frases
247
+ answer_covered = False
248
+ covered_sentences_count = 0
249
+ coverage_details = []
250
+ coverage_percentage = 0.0 # Inicializa coverage_percentage
251
+
252
+ # Divide a resposta ideal em frases e as limpa
253
+ # Usa um regex mais robusto para split de frases, considerando múltiplos delimitadores
254
+ ideal_answer_sentences_raw = re.split(r'(?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<=\.|\?|\!)\s', ideal_answer)
255
+ ideal_answer_sentences = [clean_text_for_embedding(s).strip() for s in ideal_answer_sentences_raw if clean_text_for_embedding(s).strip()]
256
+
257
+ if not ideal_answer_sentences:
258
+ print(f" Aviso: Resposta ideal vazia ou não divisível em frases após limpeza para '{question}'.")
259
+ status = "Resposta Ideal Vazia/Inválida"
260
+ else:
261
+ for ideal_sentence in ideal_answer_sentences:
262
+ sentence_embedding = generate_embedding_with_retry(ideal_sentence)
263
+
264
+ sentence_covered_by_chunk = False
265
+ best_similarity_for_sentence = 0.0
266
+ covered_by_chunk_info = "N/A"
267
+
268
+ if sentence_embedding is None:
269
+ coverage_details.append({
270
+ "frase_ideal": ideal_sentence,
271
+ "status": "Falha no Embedding da Frase",
272
+ "similaridade_max": 0.0,
273
+ "chunk_correspondente": "N/A"
274
+ })
275
+ continue
276
+
277
+ for item in relevant_chunks_with_similarity:
278
+ chunk_content_for_embedding = item['chunk'].get('embedding') # Usa o embedding do chunk diretamente
279
+ if chunk_content_for_embedding: # Verifica se o embedding do chunk é válido
280
+ current_similarity = cosine_similarity(sentence_embedding, chunk_content_for_embedding)
281
+ if current_similarity > best_similarity_for_sentence:
282
+ best_similarity_for_sentence = current_similarity
283
+ covered_by_chunk_info = f"Doc: {item['chunk'].get('document_title', 'N/A')} | Sec: {item['chunk'].get('chunk_title', 'N/A')}"
284
+
285
+ if current_similarity >= MIN_SENTENCE_SIMILARITY_THRESHOLD:
286
+ sentence_covered_by_chunk = True
287
+ break # Já encontrou um chunk relevante para esta frase
288
+
289
+ if sentence_covered_by_chunk:
290
+ covered_sentences_count += 1
291
+
292
+ coverage_details.append({
293
+ "frase_ideal": ideal_sentence,
294
+ "status": "Coberta" if sentence_covered_by_chunk else "Não Coberta",
295
+ "similaridade_max": f"{best_similarity_for_sentence:.4f}",
296
+ "chunk_correspondente": covered_by_chunk_info
297
+ })
298
+
299
+ # Determinar o status geral da cobertura
300
+ total_sentences = len(ideal_answer_sentences)
301
+ if total_sentences > 0:
302
+ coverage_percentage = covered_sentences_count / total_sentences
303
+ if coverage_percentage >= MIN_PHRASES_COVERED_PERCENTAGE:
304
+ answer_covered = True
305
+
306
+ status = "Encontrada (Cobertura Suficiente)" if answer_covered else "Não Encontrada (Cobertura Insuficiente)"
307
+ print(f" Status: {status}. Frases cobertas: {covered_sentences_count}/{total_sentences} ({coverage_percentage*100:.2f}%)")
308
+
309
+ if answer_covered:
310
+ found_in_top_k_count += 1
311
+
312
+ evaluation_results.append({
313
+ "pergunta": question,
314
+ "resposta_ideal": ideal_answer,
315
+ "status": status,
316
+ "cobertura_detalhes": coverage_details,
317
+ "top_k_chunks_relevantes": top_chunks_report # Adiciona os chunks mais relevantes para a pergunta
318
+ })
319
+
320
+ # Salvar resultados da avaliação
321
+ # MODIFICADO: usa output_json_path
322
+ try:
323
+ with open(output_json_path, 'w', encoding='utf-8') as f:
324
+ json.dump(evaluation_results, f, ensure_ascii=False, indent=4)
325
+ print(f"\nResultados da avaliação salvos em '{output_json_path}'.")
326
+ except Exception as e:
327
+ print(f"Erro ao salvar os resultados da avaliação: {e}")
328
+ return False
329
+
330
+ print(f"\n--- Resumo da Avaliação ---")
331
+ print(f"Total de perguntas avaliadas: {total_questions}")
332
+ if total_questions > 0:
333
+ print(f"Perguntas consideradas 'Encontradas (Cobertura Suficiente)': {found_in_top_k_count}")
334
+ print(f"Porcentagem de cobertura geral: {(found_in_top_k_count / total_questions * 100):.2f}%")
335
+ else:
336
+ print("Nenhuma pergunta foi avaliada.")
337
+
338
+
339
+ return True
340
+
341
+ def cli_main():
342
+ parser = argparse.ArgumentParser(description="Avalia a cobertura da documentação usando embeddings.")
343
+ parser.add_argument("qa_filepath", help="Caminho para o arquivo CSV de perguntas e respostas ideais.")
344
+ parser.add_argument("embeddings_filepath", help="Caminho para o arquivo JSON de chunks processados com embeddings.")
345
+ parser.add_argument("-k", "--top_k_chunks", type=int, default=5, help="Número de chunks mais relevantes a considerar (padrão: 5).")
346
+ parser.add_argument("-o", "--output", default="evaluation_results.json", help="Arquivo de saída para os resultados da avaliação (padrão: evaluation_results.json).")
347
+ args = parser.parse_args()
348
+
349
+ success = evaluate_coverage(
350
+ qa_filepath=args.qa_filepath,
351
+ chunks_filepath=args.embeddings_filepath,
352
+ top_k_chunks=args.top_k_chunks,
353
+ output_json_path=args.output
354
+ )
355
+ if not success:
356
+ print("\nA avaliação de cobertura da documentação falhou.")
357
+ sys.exit(1)
358
+ else:
359
+ print("\nA avaliação de cobertura da documentação foi concluída com sucesso.")
360
+
361
+ if __name__ == "__main__":
362
+ cli_main() # Now it calls the cli_main function
@@ -0,0 +1,128 @@
1
+ # Novo ou modificado: extract_consolidated_md_to_raw_json.py
2
+ import re
3
+ import json
4
+ import os
5
+
6
+ def extract_docs_from_consolidated_md(input_md_path="corpus_consolidated.md", output_json_path="raw_docs.json"):
7
+ """
8
+ Lê o arquivo MD consolidado, divide-o em documentos individuais
9
+ e extrai seu conteúdo e metadados para raw_docs.json.
10
+ """
11
+ if not os.path.exists(input_md_path):
12
+ print(f"Erro: O arquivo consolidado '{input_md_path}' não foi encontrado.")
13
+ return False
14
+
15
+ print(f"Extraindo documentos de '{input_md_path}'...")
16
+
17
+ with open(input_md_path, 'r', encoding='utf-8') as f:
18
+ full_content = f.read()
19
+
20
+ # Padrão para identificar o início de um novo documento e capturar o caminho do arquivo
21
+ # E também o conteúdo de metadados
22
+ doc_sections = re.split(r'(^## Arquivo: (.*?)\.md$)', full_content, flags=re.MULTILINE)
23
+
24
+ extracted_docs = []
25
+ current_doc_filepath = None
26
+ current_doc_content = []
27
+
28
+ # Ignora o primeiro elemento de doc_sections se estiver vazio (antes do primeiro cabeçalho)
29
+ # ou se for a primeira quebra
30
+ for i, section_part in enumerate(doc_sections):
31
+ if i == 0:
32
+ continue # Ignora o que vem antes do primeiro "## Arquivo:"
33
+
34
+ if section_part.startswith("## Arquivo:"): # É o cabeçalho de um novo documento
35
+ # Se já temos conteúdo do documento anterior, salve-o
36
+ if current_doc_filepath and current_doc_content:
37
+ # Extrair metadados e conteúdo
38
+ full_doc_content_str = "\n".join(current_doc_content).strip()
39
+ title, slug, content_for_doc = extract_metadata_and_content(full_doc_content_str, current_doc_filepath)
40
+ extracted_docs.append({
41
+ "title": title,
42
+ "slug": slug,
43
+ "content": content_for_doc,
44
+ "filepath": current_doc_filepath
45
+ })
46
+
47
+ # Iniciar um novo documento
48
+ match = re.match(r'^## Arquivo: (.*?)\.md$', section_part.strip())
49
+ if match:
50
+ current_doc_filepath = match.group(1) + ".md" # Recompõe o filepath
51
+ current_doc_content = [] # Reseta o conteúdo
52
+ elif i % 2 != 0: # Isso pega o conteúdo que segue o cabeçalho '## Arquivo:'
53
+ # Este é o conteúdo completo do documento, incluindo o Metadata_Start/End
54
+ current_doc_content.append(section_part.strip())
55
+ # else: este é o grupo de captura do regex, ignoramos aqui
56
+
57
+ # Salva o último documento após o loop
58
+ if current_doc_filepath and current_doc_content:
59
+ full_doc_content_str = "\n".join(current_doc_content).strip()
60
+ title, slug, content_for_doc = extract_metadata_and_content(full_doc_content_str, current_doc_filepath)
61
+ extracted_docs.append({
62
+ "title": title,
63
+ "slug": slug,
64
+ "content": content_for_doc,
65
+ "filepath": current_doc_filepath
66
+ })
67
+
68
+ # Verifica se algum documento foi realmente extraído
69
+ if not extracted_docs:
70
+ print("Atenção: Nenhum documento válido foi extraído do arquivo consolidado.")
71
+ return False
72
+
73
+ try:
74
+ with open(output_json_path, 'w', encoding='utf-8') as f:
75
+ json.dump(extracted_docs, f, ensure_ascii=False, indent=4)
76
+ print(f"Extração concluída. Salvou {len(extracted_docs)} documentos em '{output_json_path}'.")
77
+ return True
78
+ except Exception as e:
79
+ print(f"Erro ao salvar o arquivo JSON de documentos brutos: {e}")
80
+ return False
81
+
82
+ def extract_metadata_and_content(doc_full_text, default_filepath):
83
+ """
84
+ Extrai metadados (title, slug) e o conteúdo principal de um bloco de documento,
85
+ ignorando as seções Metadata_Start/End.
86
+ """
87
+ title = "Título Desconhecido"
88
+ slug = ""
89
+ content = doc_full_text
90
+
91
+ # Expressão regular para encontrar o bloco Metadata_Start/End
92
+ metadata_match = re.search(r'## Metadata_Start(.*?)## Metadata_End', doc_full_text, re.DOTALL)
93
+
94
+ if metadata_match:
95
+ metadata_block = metadata_match.group(1)
96
+ # Extrai title e slug do bloco de metadados
97
+ title_match = re.search(r'## title: (.*)', metadata_block)
98
+ if title_match:
99
+ title = title_match.group(1).strip()
100
+
101
+ slug_match = re.search(r'## slug: (.*)', metadata_block)
102
+ if slug_match:
103
+ slug = slug_match.group(1).strip()
104
+
105
+ # Remove o bloco de metadados do conteúdo principal
106
+ content = re.sub(r'## Metadata_Start.*?## Metadata_End', '', doc_full_text, flags=re.DOTALL).strip()
107
+
108
+ # Remove o cabeçalho "## Arquivo: ..." que foi usado para dividir
109
+ content = re.sub(r'^## Arquivo: .*$', '', content, flags=re.MULTILINE).strip()
110
+
111
+ return title, slug, content
112
+
113
+ def cli_main():
114
+ import argparse
115
+ parser = argparse.ArgumentParser(description="Extrai dados do arquivo MD consolidado para JSON.")
116
+ parser.add_argument("input_md_path", help="Caminho para o arquivo MD consolidado de entrada.")
117
+ parser.add_argument("output_json_path", help="Caminho para o arquivo JSON de saída (ex: raw_docs.json).")
118
+ args = parser.parse_args()
119
+ success = extract_docs_from_consolidated_md(args.input_md_path, args.output_json_path)
120
+ if not success:
121
+ print("A extração de documentos do MD consolidado falhou.")
122
+ sys.exit(1)
123
+ else:
124
+ print("Extração de documentos do MD consolidado concluída com sucesso.")
125
+
126
+ if __name__ == "__main__":
127
+ import sys # Adicione se não estiver importado
128
+ cli_main()