bioguider 0.2.52__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.
Files changed (84) hide show
  1. bioguider/__init__.py +0 -0
  2. bioguider/agents/__init__.py +0 -0
  3. bioguider/agents/agent_task.py +92 -0
  4. bioguider/agents/agent_tools.py +176 -0
  5. bioguider/agents/agent_utils.py +504 -0
  6. bioguider/agents/collection_execute_step.py +182 -0
  7. bioguider/agents/collection_observe_step.py +125 -0
  8. bioguider/agents/collection_plan_step.py +156 -0
  9. bioguider/agents/collection_task.py +184 -0
  10. bioguider/agents/collection_task_utils.py +142 -0
  11. bioguider/agents/common_agent.py +137 -0
  12. bioguider/agents/common_agent_2step.py +215 -0
  13. bioguider/agents/common_conversation.py +61 -0
  14. bioguider/agents/common_step.py +85 -0
  15. bioguider/agents/consistency_collection_step.py +102 -0
  16. bioguider/agents/consistency_evaluation_task.py +57 -0
  17. bioguider/agents/consistency_evaluation_task_utils.py +14 -0
  18. bioguider/agents/consistency_observe_step.py +110 -0
  19. bioguider/agents/consistency_query_step.py +77 -0
  20. bioguider/agents/dockergeneration_execute_step.py +186 -0
  21. bioguider/agents/dockergeneration_observe_step.py +154 -0
  22. bioguider/agents/dockergeneration_plan_step.py +158 -0
  23. bioguider/agents/dockergeneration_task.py +158 -0
  24. bioguider/agents/dockergeneration_task_utils.py +220 -0
  25. bioguider/agents/evaluation_installation_task.py +270 -0
  26. bioguider/agents/evaluation_readme_task.py +767 -0
  27. bioguider/agents/evaluation_submission_requirements_task.py +172 -0
  28. bioguider/agents/evaluation_task.py +206 -0
  29. bioguider/agents/evaluation_tutorial_task.py +169 -0
  30. bioguider/agents/evaluation_tutorial_task_prompts.py +187 -0
  31. bioguider/agents/evaluation_userguide_prompts.py +179 -0
  32. bioguider/agents/evaluation_userguide_task.py +154 -0
  33. bioguider/agents/evaluation_utils.py +127 -0
  34. bioguider/agents/identification_execute_step.py +181 -0
  35. bioguider/agents/identification_observe_step.py +104 -0
  36. bioguider/agents/identification_plan_step.py +140 -0
  37. bioguider/agents/identification_task.py +270 -0
  38. bioguider/agents/identification_task_utils.py +22 -0
  39. bioguider/agents/peo_common_step.py +64 -0
  40. bioguider/agents/prompt_utils.py +253 -0
  41. bioguider/agents/python_ast_repl_tool.py +69 -0
  42. bioguider/agents/rag_collection_task.py +130 -0
  43. bioguider/conversation.py +67 -0
  44. bioguider/database/code_structure_db.py +500 -0
  45. bioguider/database/summarized_file_db.py +146 -0
  46. bioguider/generation/__init__.py +39 -0
  47. bioguider/generation/benchmark_metrics.py +610 -0
  48. bioguider/generation/change_planner.py +189 -0
  49. bioguider/generation/document_renderer.py +157 -0
  50. bioguider/generation/llm_cleaner.py +67 -0
  51. bioguider/generation/llm_content_generator.py +1128 -0
  52. bioguider/generation/llm_injector.py +809 -0
  53. bioguider/generation/models.py +85 -0
  54. bioguider/generation/output_manager.py +74 -0
  55. bioguider/generation/repo_reader.py +37 -0
  56. bioguider/generation/report_loader.py +166 -0
  57. bioguider/generation/style_analyzer.py +36 -0
  58. bioguider/generation/suggestion_extractor.py +436 -0
  59. bioguider/generation/test_metrics.py +189 -0
  60. bioguider/managers/benchmark_manager.py +785 -0
  61. bioguider/managers/evaluation_manager.py +215 -0
  62. bioguider/managers/generation_manager.py +686 -0
  63. bioguider/managers/generation_test_manager.py +107 -0
  64. bioguider/managers/generation_test_manager_v2.py +525 -0
  65. bioguider/rag/__init__.py +0 -0
  66. bioguider/rag/config.py +117 -0
  67. bioguider/rag/data_pipeline.py +651 -0
  68. bioguider/rag/embedder.py +24 -0
  69. bioguider/rag/rag.py +138 -0
  70. bioguider/settings.py +103 -0
  71. bioguider/utils/code_structure_builder.py +59 -0
  72. bioguider/utils/constants.py +135 -0
  73. bioguider/utils/default.gitignore +140 -0
  74. bioguider/utils/file_utils.py +215 -0
  75. bioguider/utils/gitignore_checker.py +175 -0
  76. bioguider/utils/notebook_utils.py +117 -0
  77. bioguider/utils/pyphen_utils.py +73 -0
  78. bioguider/utils/python_file_handler.py +65 -0
  79. bioguider/utils/r_file_handler.py +551 -0
  80. bioguider/utils/utils.py +163 -0
  81. bioguider-0.2.52.dist-info/LICENSE +21 -0
  82. bioguider-0.2.52.dist-info/METADATA +51 -0
  83. bioguider-0.2.52.dist-info/RECORD +84 -0
  84. bioguider-0.2.52.dist-info/WHEEL +4 -0
@@ -0,0 +1,686 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Optional, Tuple, Dict, List
6
+ import json
7
+ from datetime import datetime
8
+
9
+ from bioguider.generation import (
10
+ EvaluationReportLoader,
11
+ SuggestionExtractor,
12
+ RepoReader,
13
+ StyleAnalyzer,
14
+ ChangePlanner,
15
+ DocumentRenderer,
16
+ OutputManager,
17
+ LLMContentGenerator,
18
+ LLMCleaner,
19
+ )
20
+ from bioguider.generation.models import GenerationManifest, GenerationReport
21
+ from bioguider.utils.file_utils import parse_repo_url
22
+
23
+
24
+ class DocumentationGenerationManager:
25
+ def __init__(self, llm, step_callback, output_dir: Optional[str] = None):
26
+ self.llm = llm
27
+ self.step_callback = step_callback
28
+ self.repo_url_or_path: str | None = None
29
+ self.start_time = None
30
+
31
+ self.loader = EvaluationReportLoader()
32
+ self.extractor = SuggestionExtractor()
33
+ self.style_analyzer = StyleAnalyzer()
34
+ self.planner = ChangePlanner()
35
+ self.renderer = DocumentRenderer()
36
+ self.output = OutputManager(base_outputs_dir=output_dir)
37
+ self.llm_gen = LLMContentGenerator(llm)
38
+ self.llm_cleaner = LLMCleaner(llm)
39
+
40
+
41
+ def print_step(self, step_name: str | None = None, step_output: str | None = None):
42
+ if self.step_callback is None:
43
+ return
44
+ self.step_callback(step_name=step_name, step_output=step_output)
45
+
46
+ def prepare_repo(self, repo_url_or_path: str):
47
+ self.repo_url_or_path = repo_url_or_path
48
+
49
+ def _get_generation_time(self) -> str:
50
+ """Get formatted generation time with start, end, and duration"""
51
+ if self.start_time is None:
52
+ return "Not tracked"
53
+ import time
54
+ import datetime
55
+ end_time = time.time()
56
+ duration = end_time - self.start_time
57
+
58
+ start_str = datetime.datetime.fromtimestamp(self.start_time).strftime("%H:%M:%S")
59
+ end_str = datetime.datetime.fromtimestamp(end_time).strftime("%H:%M:%S")
60
+
61
+ if duration < 60:
62
+ duration_str = f"{duration:.1f}s"
63
+ elif duration < 3600:
64
+ duration_str = f"{duration/60:.1f}m"
65
+ else:
66
+ duration_str = f"{duration/3600:.1f}h"
67
+
68
+ return f"{start_str} → {end_str} ({duration_str})"
69
+
70
+ def run(self, report_path: str, repo_path: str | None = None, target_files: List[str] | None = None, max_files: int | None = None) -> str:
71
+ """
72
+ Run the documentation generation pipeline.
73
+
74
+ Args:
75
+ report_path: Path to the evaluation report JSON
76
+ repo_path: Path to the repository (optional)
77
+ target_files: Optional list of file paths to limit processing to.
78
+ If provided, only these files will be processed.
79
+ max_files: Optional hard limit on number of files to process.
80
+ If provided, only the first N files will be processed.
81
+ """
82
+ import time
83
+ self.start_time = time.time()
84
+ repo_path = repo_path or self.repo_url_or_path or ""
85
+ self.print_step(step_name="LoadReport", step_output=f"Loading evaluation report from {report_path}...")
86
+ report, report_abs = self.loader.load(report_path)
87
+ self.print_step(step_name="LoadReport", step_output="✓ Evaluation report loaded successfully")
88
+
89
+ self.print_step(step_name="ReadRepoFiles", step_output=f"Reading repository files from {repo_path}...")
90
+ reader = RepoReader(repo_path)
91
+ # Prefer report-listed files if available; include all report-declared file lists
92
+ target_files = []
93
+ if getattr(report, "readme_files", None):
94
+ target_files.extend(report.readme_files)
95
+ if getattr(report, "installation_files", None):
96
+ target_files.extend(report.installation_files)
97
+ # If userguide_files not explicitly provided, derive from userguide_evaluation keys
98
+ userguide_files: list[str] = []
99
+ if getattr(report, "userguide_files", None):
100
+ userguide_files.extend([p for p in report.userguide_files if isinstance(p, str)])
101
+ elif getattr(report, "userguide_evaluation", None) and isinstance(report.userguide_evaluation, dict):
102
+ for key in report.userguide_evaluation.keys():
103
+ if isinstance(key, str) and key.strip():
104
+ userguide_files.append(key)
105
+ target_files.extend(userguide_files)
106
+
107
+ # Add tutorial files from tutorial_evaluation keys
108
+ tutorial_files: list[str] = []
109
+ if getattr(report, "tutorial_files", None):
110
+ tutorial_files.extend([p for p in report.tutorial_files if isinstance(p, str)])
111
+ elif getattr(report, "tutorial_evaluation", None) and isinstance(report.tutorial_evaluation, dict):
112
+ for key in report.tutorial_evaluation.keys():
113
+ if isinstance(key, str) and key.strip():
114
+ tutorial_files.append(key)
115
+ target_files.extend(tutorial_files)
116
+
117
+ if getattr(report, "submission_requirements_files", None):
118
+ target_files.extend(report.submission_requirements_files)
119
+ target_files = [p for p in target_files if isinstance(p, str) and p.strip()]
120
+ target_files = list(dict.fromkeys(target_files)) # de-dup
121
+ files, missing = reader.read_files(target_files) if target_files else reader.read_default_targets()
122
+ self.print_step(step_name="ReadRepoFiles", step_output=f"✓ Read {len(files)} files from repository")
123
+
124
+ # EARLY FILTER: If target_files specified, limit which files get processed
125
+ # This is the most effective filter - applied BEFORE suggestions are extracted
126
+ if target_files:
127
+ # Normalize target file paths for matching
128
+ target_basenames = {os.path.basename(f) for f in target_files}
129
+ target_paths = set(target_files)
130
+
131
+ # Filter files dict to only include target files
132
+ original_count = len(files)
133
+ filtered_files = {}
134
+ for fpath, content in files.items():
135
+ # Match by full path or basename
136
+ if fpath in target_paths or os.path.basename(fpath) in target_basenames:
137
+ filtered_files[fpath] = content
138
+
139
+ if len(filtered_files) < original_count:
140
+ self.print_step(step_name="FilterFiles", step_output=f"Limiting to {len(filtered_files)} target files (from {original_count})")
141
+ files = filtered_files
142
+
143
+ self.print_step(step_name="AnalyzeStyle", step_output="Analyzing document style and formatting...")
144
+ style = self.style_analyzer.analyze(files)
145
+ self.print_step(step_name="AnalyzeStyle", step_output="✓ Document style analysis completed")
146
+
147
+ self.print_step(step_name="ExtractSuggestions", step_output="Extracting suggestions from evaluation report...")
148
+ suggestions = self.extractor.extract(report)
149
+ self.print_step(step_name="Suggestions", step_output=f"✓ Extracted {len(suggestions)} suggestions from evaluation report")
150
+
151
+ self.print_step(step_name="PlanChanges", step_output="Planning changes based on suggestions...")
152
+ plan = self.planner.build_plan(repo_path=repo_path, style=style, suggestions=suggestions, available_files=files)
153
+ self.print_step(step_name="PlannedEdits", step_output=f"✓ Planned {len(plan.planned_edits)} edits across {len(set(e.file_path for e in plan.planned_edits))} files")
154
+
155
+ self.print_step(step_name="RenderDocuments", step_output=f"Rendering documents with LLM (processing {len(plan.planned_edits)} edits)...")
156
+ # Apply edits; support full-file regeneration using the evaluation report as the sole authority
157
+ revised: Dict[str, str] = {}
158
+ diff_stats: Dict[str, dict] = {}
159
+ edits_by_file: Dict[str, list] = {}
160
+ for e in plan.planned_edits:
161
+ edits_by_file.setdefault(e.file_path, []).append(e)
162
+
163
+ # FILTER: Only process target files if specified
164
+ if target_files:
165
+ # Build multiple matching sets for robust path comparison
166
+ target_basenames = {os.path.basename(f) for f in target_files}
167
+ target_paths = set(target_files)
168
+ # Also normalize paths
169
+ target_normalized = {os.path.normpath(f) for f in target_files}
170
+
171
+ self.print_step(step_name="FilterDebug", step_output=f"Target files: {list(target_files)[:5]}, edits keys: {list(edits_by_file.keys())[:5]}")
172
+
173
+ filtered_edits = {}
174
+ for fpath, edits in edits_by_file.items():
175
+ fpath_norm = os.path.normpath(fpath)
176
+ fpath_base = os.path.basename(fpath)
177
+ # Match by any of: exact path, normalized path, or basename
178
+ if (fpath in target_paths or
179
+ fpath_norm in target_normalized or
180
+ fpath_base in target_basenames):
181
+ filtered_edits[fpath] = edits
182
+
183
+ skipped_count = len(edits_by_file) - len(filtered_edits)
184
+ self.print_step(step_name="FilterEdits", step_output=f"Matched {len(filtered_edits)} of {len(edits_by_file)} files (skipping {skipped_count})")
185
+ edits_by_file = filtered_edits
186
+
187
+ # HARD LIMIT: Fallback if filter didn't work or max_files specified
188
+ if max_files and max_files > 0 and len(edits_by_file) > max_files:
189
+ all_files = list(edits_by_file.keys())
190
+ limited_files = all_files[:max_files]
191
+ original_count = len(edits_by_file)
192
+ edits_by_file = {k: edits_by_file[k] for k in limited_files}
193
+ self.print_step(step_name="HardLimit", step_output=f"Limited to {len(edits_by_file)} files (from {original_count})")
194
+
195
+ total_files = len(edits_by_file)
196
+ processed_files = 0
197
+
198
+ # Prepare evaluation data subset to drive LLM full document generation
199
+ evaluation_data = {
200
+ "readme_evaluation": getattr(report, "readme_evaluation", None),
201
+ "installation_evaluation": getattr(report, "installation_evaluation", None),
202
+ "userguide_evaluation": getattr(report, "userguide_evaluation", None),
203
+ "tutorial_evaluation": getattr(report, "tutorial_evaluation", None),
204
+ }
205
+
206
+ for fpath, edits in edits_by_file.items():
207
+ processed_files += 1
208
+ self.print_step(step_name="ProcessingFile", step_output=f"Processing {fpath} ({processed_files}/{total_files}) - {len(edits)} edits")
209
+
210
+ original_content = files.get(fpath, "")
211
+
212
+ # Group suggestions by file to avoid duplicate generation
213
+ file_suggestions = []
214
+ full_replace_edits = []
215
+ section_edits = []
216
+
217
+ for e in edits:
218
+ suggestion = next((s for s in suggestions if s.id == e.suggestion_id), None) if e.suggestion_id else None
219
+ if suggestion:
220
+ file_suggestions.append(suggestion)
221
+ if e.edit_type == "full_replace":
222
+ full_replace_edits.append(e)
223
+ else:
224
+ section_edits.append(e)
225
+
226
+ # Debug: Save suggestion grouping info
227
+ debug_dir = "outputs/debug_generation"
228
+ os.makedirs(debug_dir, exist_ok=True)
229
+ safe_filename = fpath.replace("/", "_").replace(".", "_")
230
+
231
+ grouping_info = {
232
+ "file_path": fpath,
233
+ "total_edits": len(edits),
234
+ "file_suggestions_count": len(file_suggestions),
235
+ "full_replace_edits_count": len(full_replace_edits),
236
+ "section_edits_count": len(section_edits),
237
+ "suggestions": [
238
+ {
239
+ "id": s.id,
240
+ "category": s.category,
241
+ "content_guidance": s.content_guidance[:200] + "..." if len(s.content_guidance or "") > 200 else s.content_guidance,
242
+ "target_files": s.target_files
243
+ } for s in file_suggestions
244
+ ],
245
+ "timestamp": datetime.now().isoformat()
246
+ }
247
+
248
+ grouping_file = os.path.join(debug_dir, f"{safe_filename}_grouping.json")
249
+ with open(grouping_file, 'w', encoding='utf-8') as f:
250
+ json.dump(grouping_info, f, indent=2, ensure_ascii=False)
251
+
252
+ content = original_content
253
+ total_stats = {"added_lines": 0}
254
+
255
+ # CRITICAL: Generate content ONCE per file if there are full_replace edits
256
+ # All suggestions for this file are merged into a single evaluation report
257
+ # This prevents duplicate content generation
258
+ if full_replace_edits:
259
+ self.print_step(
260
+ step_name="GeneratingContent",
261
+ step_output=f"🔄 Generating full document for {fpath} with {len(file_suggestions)} suggestions using LLM (SINGLE CALL)..."
262
+ )
263
+
264
+ # Merge all suggestions for this file into a single evaluation report
265
+ # Format suggestions with clear numbering to help LLM understand they're separate improvements
266
+ suggestions_list = []
267
+ for idx, s in enumerate(file_suggestions, 1):
268
+ suggestions_list.append({
269
+ "suggestion_number": idx,
270
+ "category": s.category if hasattr(s, 'category') else "general",
271
+ "content_guidance": s.content_guidance
272
+ })
273
+
274
+ merged_evaluation_report = {
275
+ "total_suggestions": len(file_suggestions),
276
+ "integration_instruction": f"Integrate ALL {len(file_suggestions)} suggestions below into ONE cohesive document. Do NOT create {len(file_suggestions)} separate versions.",
277
+ "suggestions": suggestions_list
278
+ }
279
+
280
+ # Debug: Save merged evaluation report
281
+ merged_report_file = os.path.join(debug_dir, f"{safe_filename}_merged_report.json")
282
+ with open(merged_report_file, 'w', encoding='utf-8') as f:
283
+ json.dump(merged_evaluation_report, f, indent=2, ensure_ascii=False)
284
+
285
+ # Debug: Log that we're about to make a single generation call
286
+ debug_log_file = os.path.join(debug_dir, f"{safe_filename}_generation_log.txt")
287
+ with open(debug_log_file, 'a', encoding='utf-8') as f:
288
+ f.write(f"\n=== GENERATION CALL at {datetime.now().isoformat()} ===\n")
289
+ f.write(f"File: {fpath}\n")
290
+ f.write(f"Full replace edits: {len(full_replace_edits)}\n")
291
+ f.write(f"Total suggestions: {len(file_suggestions)}\n")
292
+ f.write(f"Merged into single call: YES\n")
293
+ f.write(f"Suggestion IDs: {[s.id for s in file_suggestions]}\n\n")
294
+
295
+ gen_content, gen_usage = self.llm_gen.generate_full_document(
296
+ target_file=fpath,
297
+ evaluation_report=merged_evaluation_report,
298
+ context=original_content,
299
+ original_content=original_content,
300
+ )
301
+
302
+ # Debug: Log completion
303
+ with open(debug_log_file, 'a', encoding='utf-8') as f:
304
+ f.write(f"Generation completed at {datetime.now().isoformat()}\n")
305
+ f.write(f"Content length: {len(gen_content) if isinstance(gen_content, str) else 0} characters\n")
306
+ f.write(f"Tokens used: {gen_usage.get('total_tokens', 0)}\n")
307
+ f.write(f"SUCCESS: {isinstance(gen_content, str) and gen_content}\n\n")
308
+
309
+ if isinstance(gen_content, str) and gen_content:
310
+ self.print_step(step_name="LLMFullDoc", step_output=f"✓ Generated full document for {fpath} ({gen_usage.get('total_tokens', 0)} tokens)")
311
+ # Apply the generated content to all full_replace edits
312
+ for e in full_replace_edits:
313
+ e.content_template = gen_content
314
+ content = gen_content
315
+ else:
316
+ # Fallback: try individual generation but only for the first edit to avoid duplicates
317
+ if full_replace_edits:
318
+ e = full_replace_edits[0] # Only process the first edit
319
+ suggestion = next((s for s in suggestions if s.id == e.suggestion_id), None) if e.suggestion_id else None
320
+ if suggestion and (not e.content_template or e.content_template.strip() == ""):
321
+ self.print_step(step_name="GeneratingContent", step_output=f"Fallback: Generating full document for {e.suggestion_id} using LLM...")
322
+ gen_content, gen_usage = self.llm_gen.generate_full_document(
323
+ target_file=e.file_path,
324
+ evaluation_report={"suggestion": suggestion.content_guidance},
325
+ context=original_content,
326
+ original_content=original_content,
327
+ )
328
+ if isinstance(gen_content, str) and gen_content:
329
+ self.print_step(step_name="LLMFullDoc", step_output=f"✓ Generated full document for {e.suggestion_id} ({gen_usage.get('total_tokens', 0)} tokens)")
330
+ # Apply the same content to all full_replace edits
331
+ for edit in full_replace_edits:
332
+ edit.content_template = gen_content
333
+ content = gen_content
334
+ else:
335
+ # Handle section edits individually
336
+ for e in section_edits:
337
+ suggestion = next((s for s in suggestions if s.id == e.suggestion_id), None) if e.suggestion_id else None
338
+ if suggestion and (not e.content_template or e.content_template.strip() == ""):
339
+ self.print_step(step_name="GeneratingContent", step_output=f"Generating section for {e.suggestion_id} using LLM...")
340
+ gen_section, gen_usage = self.llm_gen.generate_section(
341
+ suggestion=suggestion,
342
+ style=plan.style_profile,
343
+ context=original_content,
344
+ )
345
+ if isinstance(gen_section, str) and gen_section:
346
+ self.print_step(step_name="LLMSection", step_output=f"✓ Generated section for {e.suggestion_id} ({gen_usage.get('total_tokens', 0)} tokens)")
347
+ # Ensure header present
348
+ if gen_section.lstrip().startswith("#"):
349
+ e.content_template = gen_section
350
+ else:
351
+ title = e.anchor.get('value', '').strip() or ''
352
+ e.content_template = f"## {title}\n\n{gen_section}" if title else gen_section
353
+
354
+ content, stats = self.renderer.apply_edit(content, e)
355
+ total_stats["added_lines"] = total_stats.get("added_lines", 0) + stats.get("added_lines", 0)
356
+
357
+ # Apply remaining edits that weren't full_replace
358
+ for e in edits:
359
+ if e.edit_type != "full_replace":
360
+ content, stats = self.renderer.apply_edit(content, e)
361
+ total_stats["added_lines"] = total_stats.get("added_lines", 0) + stats.get("added_lines", 0)
362
+
363
+ # After applying full document or section changes, run a general cleaner pass for all text files
364
+ # to fix markdown/formatting issues without changing meaning.
365
+ try:
366
+ if fpath.endswith((".md", ".rst", ".Rmd", ".Rd")) and content:
367
+ self.print_step(step_name="CleaningContent", step_output=f"Cleaning formatting for {fpath}...")
368
+ cleaned, _usage = self.llm_cleaner.clean_readme(content)
369
+ if isinstance(cleaned, str) and cleaned.strip():
370
+ content = cleaned
371
+
372
+ # LLM cleaner now handles markdown fences and unwanted summaries
373
+
374
+ except Exception:
375
+ pass
376
+
377
+ revised[fpath] = content
378
+ diff_stats[fpath] = total_stats
379
+ self.print_step(step_name="RenderedFile", step_output=f"✓ Completed {fpath} - added {total_stats['added_lines']} lines")
380
+
381
+ # Removed cleaner: duplication and fixes handled in prompts and renderer
382
+
383
+ # Prefer local repo folder name for outputs; fallback to author_repo from URL
384
+ out_repo_key = None
385
+ if repo_path and os.path.isdir(repo_path):
386
+ out_repo_key = os.path.basename(os.path.normpath(repo_path))
387
+ elif report.repo_url:
388
+ try:
389
+ author, name = parse_repo_url(report.repo_url)
390
+ out_repo_key = f"{author}_{name}"
391
+ except Exception:
392
+ out_repo_key = report.repo_url
393
+ else:
394
+ out_repo_key = self.repo_url_or_path or "repo"
395
+
396
+ self.print_step(step_name="WriteOutputs", step_output=f"Writing outputs to {out_repo_key}...")
397
+ out_dir = self.output.prepare_output_dir(out_repo_key)
398
+ # Ensure all files we read (even without edits) are written to outputs alongside revisions
399
+ all_files_to_write: Dict[str, str] = dict(files)
400
+ all_files_to_write.update(revised)
401
+ # Also copy originals next to the new files for side-by-side comparison
402
+ def original_copy_name(path: str) -> str:
403
+ # Handle all file extensions properly
404
+ if "." in path:
405
+ base, ext = path.rsplit(".", 1)
406
+ return f"{base}.original.{ext}"
407
+ return f"{path}.original"
408
+
409
+ for orig_path, orig_content in files.items():
410
+ all_files_to_write[original_copy_name(orig_path)] = orig_content
411
+
412
+ self.print_step(step_name="WritingFiles", step_output=f"Writing {len(all_files_to_write)} files to output directory...")
413
+ artifacts = self.output.write_files(out_dir, all_files_to_write, diff_stats_by_file=diff_stats)
414
+
415
+ manifest = GenerationManifest(
416
+ repo_url=report.repo_url,
417
+ report_path=report_abs,
418
+ output_dir=out_dir,
419
+ suggestions=suggestions,
420
+ planned_edits=plan.planned_edits,
421
+ artifacts=artifacts,
422
+ skipped=missing,
423
+ )
424
+ self.print_step(step_name="WritingManifest", step_output="Writing generation manifest...")
425
+ self.output.write_manifest(out_dir, manifest)
426
+
427
+ # Write human-readable generation report
428
+ self.print_step(step_name="WritingReport", step_output="Writing generation report...")
429
+ gen_report_path = self._write_generation_report(
430
+ out_dir,
431
+ report.repo_url or str(self.repo_url_or_path or ""),
432
+ plan,
433
+ diff_stats,
434
+ suggestions,
435
+ artifacts,
436
+ missing,
437
+ )
438
+ self.print_step(step_name="Done", step_output=f"✓ Generation completed! Output directory: {out_dir}")
439
+ return out_dir
440
+
441
+ def _write_generation_report(
442
+ self,
443
+ out_dir: str,
444
+ repo_url: str,
445
+ plan,
446
+ diff_stats: Dict[str, dict],
447
+ suggestions,
448
+ artifacts,
449
+ skipped: List[str],
450
+ ):
451
+ # Build a user-friendly markdown report
452
+ lines: list[str] = []
453
+ lines.append(f"# Documentation Generation Report\n")
454
+ lines.append(f"**Repository:** {repo_url}\n")
455
+ lines.append(f"**Generated:** {out_dir}\n")
456
+
457
+ # Processing timeline
458
+ total_improvements = len(plan.planned_edits)
459
+ start_time_str = self._get_generation_time().split(" → ")[0] if self.start_time else "Not tracked"
460
+ end_time_str = self._get_generation_time().split(" → ")[1].split(" (")[0] if self.start_time else "Not tracked"
461
+ duration_str = self._get_generation_time().split("(")[1].replace(")", "") if self.start_time else "Not tracked"
462
+
463
+ lines.append(f"**Processing Timeline:**\n")
464
+ lines.append(f"- **Start Time:** {start_time_str}\n")
465
+ lines.append(f"- **End Time:** {end_time_str}\n")
466
+ lines.append(f"- **Duration:** {duration_str}\n")
467
+
468
+ # Calculate statistics by category
469
+ category_stats = {}
470
+ file_stats = {}
471
+ for e in plan.planned_edits:
472
+ sug = next((s for s in suggestions if s.id == e.suggestion_id), None)
473
+ if sug and sug.category:
474
+ category = sug.category.split('.')[0] # e.g., "readme.dependencies" -> "readme"
475
+ category_stats[category] = category_stats.get(category, 0) + 1
476
+
477
+ file_stats[e.file_path] = file_stats.get(e.file_path, 0) + 1
478
+
479
+ # Calculate evaluation report statistics
480
+ score_stats = {"Excellent": 0, "Good": 0, "Fair": 0, "Poor": 0}
481
+ processed_suggestions = set()
482
+ for e in plan.planned_edits:
483
+ sug = next((s for s in suggestions if s.id == e.suggestion_id), None)
484
+ if sug and sug.source and sug.id not in processed_suggestions:
485
+ score = sug.source.get("score", "")
486
+ if score in score_stats:
487
+ score_stats[score] += 1
488
+ processed_suggestions.add(sug.id)
489
+
490
+ # Calculate success rate based on processed suggestions only
491
+ processed_suggestions_count = len([s for s in suggestions if s.source and s.source.get("score", "") in ("Fair", "Poor")])
492
+ fixed_suggestions = len([s for s in processed_suggestions if s in [sug.id for sug in suggestions if sug.source and sug.source.get("score", "") in ("Fair", "Poor")]])
493
+
494
+ # Add professional summary and key metrics
495
+ lines.append(f"\n## Summary\n")
496
+
497
+ # Concise summary for busy developers
498
+ lines.append(f"This is a report of automated documentation enhancements generated by BioGuider.\n")
499
+ lines.append(f"\nOur AI analyzed your existing documentation to identify areas for improvement based on standards for high-quality scientific software. It then automatically rewrote the files to be more accessible and useful for biomedical researchers.\n")
500
+ lines.append(f"\nThis changelog provides a transparent record of what was modified and why. We encourage you to review the changes before committing. Original file versions are backed up with a `.original` extension.\n")
501
+
502
+ # Core metrics
503
+ total_lines_added = sum(stats.get('added_lines', 0) for stats in diff_stats.values())
504
+ success_rate = (fixed_suggestions/processed_suggestions_count*100) if processed_suggestions_count > 0 else 0
505
+
506
+ # Lead with success rate - the most important outcome
507
+ lines.append(f"\n### Key Metrics\n")
508
+ lines.append(f"- **Success Rate:** {success_rate:.1f}% ({fixed_suggestions} of {processed_suggestions_count} processed suggestions addressed)\n")
509
+ lines.append(f"- **Total Impact:** {total_improvements} improvements across {len(file_stats)} files\n")
510
+ lines.append(f"- **Content Added:** {total_lines_added} lines of enhanced documentation\n")
511
+
512
+ # Explain why some suggestions were filtered out
513
+ total_suggestions = len(suggestions)
514
+ filtered_count = total_suggestions - processed_suggestions_count
515
+ if filtered_count > 0:
516
+ lines.append(f"\n### Processing Strategy\n")
517
+ lines.append(f"- **Suggestions filtered out:** {filtered_count} items\n")
518
+ lines.append(f"- **Reason:** Only 'Fair' and 'Poor' priority suggestions were processed\n")
519
+ lines.append(f"- **Rationale:** Focus on critical issues that need immediate attention\n")
520
+ lines.append(f"- **Quality threshold:** 'Excellent' and 'Good' suggestions already meet standards\n")
521
+
522
+ # Priority breakdown - answer "Was it important work?"
523
+ lines.append(f"\n### Priority Breakdown\n")
524
+ priority_fixed = 0
525
+ priority_total = 0
526
+
527
+ for score in ["Poor", "Fair"]:
528
+ count = score_stats[score]
529
+ if count > 0:
530
+ priority_total += count
531
+ priority_fixed += count
532
+ lines.append(f"- **{score} Priority:** {count} items → 100% addressed\n")
533
+
534
+ # Remove confusing "Critical Issues" bullet - success rate already shown above
535
+
536
+ # Quality assurance note
537
+ excellent_count = score_stats.get("Excellent", 0)
538
+ good_count = score_stats.get("Good", 0)
539
+ if excellent_count > 0 or good_count > 0:
540
+ lines.append(f"\n### Quality Assurance\n")
541
+ lines.append(f"- **High-Quality Items:** {excellent_count + good_count} suggestions already meeting standards (no changes needed)\n")
542
+
543
+ # Group improvements by file type for better readability
544
+ by_file = {}
545
+ for e in plan.planned_edits:
546
+ if e.file_path not in by_file:
547
+ by_file[e.file_path] = []
548
+ by_file[e.file_path].append(e)
549
+
550
+ lines.append(f"\n## Files Improved\n")
551
+ for file_path, edits in by_file.items():
552
+ added_lines = diff_stats.get(file_path, {}).get('added_lines', 0)
553
+ lines.append(f"\n### {file_path}\n")
554
+ lines.append(f"**Changes made:** {len(edits)} improvement(s), {added_lines} lines added\n")
555
+
556
+ for e in edits:
557
+ sug = next((s for s in suggestions if s.id == e.suggestion_id), None)
558
+ guidance = sug.content_guidance if sug else ""
559
+ section = e.anchor.get('value', 'General improvements')
560
+
561
+ # Convert technical action names to user-friendly descriptions
562
+ # Use the suggestion action if available, otherwise fall back to edit type
563
+ action_key = sug.action if sug else e.edit_type
564
+
565
+ # Generate category-based description for full_replace actions
566
+ if action_key == 'full_replace' and sug:
567
+ category = sug.category or ""
568
+ category_display = category.split('.')[-1].replace('_', ' ').title() if category else ""
569
+
570
+ # Create specific descriptions based on category
571
+ if 'readme' in category.lower():
572
+ action_desc = 'Enhanced README documentation'
573
+ elif 'tutorial' in category.lower():
574
+ action_desc = 'Improved tutorial content'
575
+ elif 'userguide' in category.lower():
576
+ action_desc = 'Enhanced user guide documentation'
577
+ elif 'installation' in category.lower():
578
+ action_desc = 'Improved installation instructions'
579
+ elif 'dependencies' in category.lower():
580
+ action_desc = 'Enhanced dependency information'
581
+ elif 'readability' in category.lower():
582
+ action_desc = 'Improved readability and clarity'
583
+ elif 'setup' in category.lower():
584
+ action_desc = 'Enhanced setup and configuration'
585
+ elif 'reproducibility' in category.lower():
586
+ action_desc = 'Improved reproducibility'
587
+ elif 'structure' in category.lower():
588
+ action_desc = 'Enhanced document structure'
589
+ elif 'code_quality' in category.lower():
590
+ action_desc = 'Improved code quality'
591
+ elif 'verification' in category.lower():
592
+ action_desc = 'Enhanced result verification'
593
+ elif 'performance' in category.lower():
594
+ action_desc = 'Added performance considerations'
595
+ elif 'context' in category.lower():
596
+ action_desc = 'Enhanced context and purpose'
597
+ elif 'error_handling' in category.lower():
598
+ action_desc = 'Improved error handling'
599
+ else:
600
+ action_desc = f'Enhanced {category_display}' if category_display else 'Comprehensive rewrite'
601
+ else:
602
+ # Use existing action descriptions for non-full_replace actions
603
+ action_desc = {
604
+ 'append_section': f'Added "{section}" section',
605
+ 'insert_after_header': f'Enhanced content in "{section}"',
606
+ 'rmarkdown_integration': f'Integrated improvements in "{section}"',
607
+ 'replace_intro_block': f'Improved "{section}" section',
608
+ 'add_dependencies_section': 'Added dependencies information',
609
+ 'add_system_requirements_section': 'Added system requirements',
610
+ 'add_hardware_requirements': 'Added hardware requirements',
611
+ 'clarify_mandatory_vs_optional': 'Clarified dependencies',
612
+ 'improve_readability': f'Improved readability in "{section}"',
613
+ 'improve_setup': f'Enhanced setup instructions in "{section}"',
614
+ 'improve_reproducibility': f'Improved reproducibility in "{section}"',
615
+ 'improve_structure': f'Enhanced structure in "{section}"',
616
+ 'improve_code_quality': f'Improved code quality in "{section}"',
617
+ 'improve_verification': f'Enhanced result verification in "{section}"',
618
+ 'improve_performance': f'Added performance notes in "{section}"',
619
+ 'improve_clarity_and_error_handling': f'Improved clarity and error handling in "{section}"',
620
+ 'improve_consistency': f'Improved consistency in "{section}"',
621
+ 'improve_context': f'Enhanced context in "{section}"',
622
+ 'improve_error_handling': f'Improved error handling in "{section}"',
623
+ 'add_overview_section': f'Added "{section}" section'
624
+ }.get(action_key, f'Improved {action_key}')
625
+
626
+ lines.append(f"- **{action_desc}**")
627
+
628
+ # Show evaluation reasoning that triggered this improvement
629
+ if sug and sug.source:
630
+ score = sug.source.get("score", "")
631
+ category = sug.category or ""
632
+
633
+ # Format category for display (e.g., "readme.dependencies" -> "Dependencies")
634
+ category_display = category.split('.')[-1].replace('_', ' ').title() if category else ""
635
+
636
+ if score and category_display:
637
+ lines.append(f" - *Reason:* [{category_display} - {score}]")
638
+ elif score:
639
+ lines.append(f" - *Reason:* [{score}]")
640
+ elif category_display:
641
+ lines.append(f" - *Reason:* [{category_display}]")
642
+
643
+ # Show what was actually implemented (different from reason)
644
+ if guidance:
645
+ # Extract key action from guidance to show what was implemented
646
+ if "dependencies" in guidance.lower():
647
+ lines.append(f" - *Implemented:* Added comprehensive dependency list with installation instructions")
648
+ elif "system requirements" in guidance.lower() or "hardware" in guidance.lower():
649
+ lines.append(f" - *Implemented:* Added system requirements and platform-specific installation details")
650
+ elif "comparative statement" in guidance.lower() or "beneficial" in guidance.lower():
651
+ lines.append(f" - *Implemented:* Added comparative analysis highlighting Seurat's advantages")
652
+ elif "readability" in guidance.lower() or "bullet" in guidance.lower():
653
+ lines.append(f" - *Implemented:* Enhanced readability with structured lists and examples")
654
+ elif "overview" in guidance.lower() or "summary" in guidance.lower():
655
+ lines.append(f" - *Implemented:* Improved overview section with clear, professional tone")
656
+ elif "accessible" in guidance.lower() or "non-experts" in guidance.lower():
657
+ lines.append(f" - *Implemented:* Simplified language for broader accessibility")
658
+ elif "examples" in guidance.lower() or "usage" in guidance.lower():
659
+ lines.append(f" - *Implemented:* Added practical examples and usage scenarios")
660
+ elif "error" in guidance.lower() or "debug" in guidance.lower():
661
+ lines.append(f" - *Implemented:* Added error handling guidance and troubleshooting tips")
662
+ elif "context" in guidance.lower() or "scenarios" in guidance.lower():
663
+ lines.append(f" - *Implemented:* Expanded context and real-world application examples")
664
+ elif "structure" in guidance.lower() or "organization" in guidance.lower():
665
+ lines.append(f" - *Implemented:* Improved document structure and organization")
666
+ else:
667
+ # Truncate long guidance to avoid repetition
668
+ short_guidance = guidance[:100] + "..." if len(guidance) > 100 else guidance
669
+ lines.append(f" - *Implemented:* {short_guidance}")
670
+
671
+ lines.append("")
672
+
673
+ # Note about skipped files
674
+ if skipped:
675
+ lines.append(f"\n## Note\n")
676
+ lines.append(f"The following files were not modified as they were not found in the repository:")
677
+ for rel in skipped:
678
+ lines.append(f"- {rel}")
679
+
680
+ report_md = "\n".join(lines)
681
+ dest = os.path.join(out_dir, "GENERATION_REPORT.md")
682
+ with open(dest, "w", encoding="utf-8") as fobj:
683
+ fobj.write(report_md)
684
+ return dest
685
+
686
+