Spaces:
Running on Zero
Running on Zero
| import re | |
| import json | |
| from pathlib import Path | |
| from modules.term_workspace import TermWorkspace | |
| from config import CITATION_DENSITY_TARGET, MIN_TOTAL_CITATIONS, MIN_STUDY_WORDS | |
| # Section markers for splitting the study | |
| SECTION_MARKERS = [ | |
| ("tahdid", "تمهيد"), | |
| ("lugha", "المبحث الأول"), | |
| ("tarikh", "المبحث الثاني"), | |
| ("madloul", "المبحث الثالث"), | |
| ("muqarana", "المبحث الرابع"), | |
| ("khatima", "المبحث الخامس"), | |
| ("masadir", "قائمة المصادر"), | |
| ] | |
| SECTION_NAMES = { | |
| "tahdid": "التمهيد", | |
| "lugha": "المبحث الأول: الدراسة اللغوية", | |
| "tarikh": "المبحث الثاني: الدراسة الاصطلاحية", | |
| "madloul": "المبحث الثالث: استعمال القطان", | |
| "muqarana": "المبحث الرابع: الدراسة التطبيقية المقارنة", | |
| "khatima": "المبحث الخامس: الخاتمة", | |
| "masadir": "قائمة المصادر", | |
| } | |
| # Broad citation patterns — catches all common inline formats | |
| CITATION_INLINE_PATTERN = ( | |
| r"\([^)]*(?:" | |
| r"ت\s*\d+" # death year: (ت 852 هـ) | |
| r"|ج\s*[:_]?\s*\d+" # volume: (ج1، ص10) or (ج:1) | |
| r"|ص\s*[:_]?\s*\d+" # page: (ص15) or (ص:15) | |
| r"|🔗" # link symbol | |
| r"|https?://" # URL | |
| r"|يحتاج توثيقاً" # needs documentation | |
| r"|المصدر السابق" # previous source reference | |
| r")[^)]*\)" | |
| ) | |
| class StudyAuditor: | |
| """Audit a completed study for quality and compliance — section-aware.""" | |
| def __init__(self, term: str, category: str): | |
| self.term = term | |
| self.category = category | |
| self.ws = TermWorkspace(term, category) | |
| self.issues = [] | |
| self.stats = {} | |
| def audit(self, text: str, draft: dict = None) -> dict: | |
| self.issues = [] | |
| self.stats = {} | |
| # Strip any existing audit report appendix first to ensure clean stats | |
| text = re.split(r"\n## ملحق: تقرير الجودة", text)[0].strip() | |
| self._count_citations(text) | |
| self._check_citation_density(text) | |
| self._check_hallucinated_links(text) | |
| self._check_structure(text) | |
| self._check_length(text) | |
| self._check_language(text) | |
| # New: section-aware analysis | |
| sections = self._split_into_sections(text) | |
| self._audit_sections(text, sections) | |
| # New: raw material utilization | |
| if draft: | |
| self._check_raw_utilization(text, draft) | |
| # New: cross-reference validation (book titles + narrators mentioned in study vs sources) | |
| self._check_cross_references(text, draft) | |
| passed = len(self.issues) == 0 | |
| severity = "PASS" if passed else "FAIL" | |
| if self.issues: | |
| severity = ( | |
| "WARN" | |
| if all(i["severity"] == "warning" for i in self.issues) | |
| else "FAIL" | |
| ) | |
| result = { | |
| "term": self.term, | |
| "category": self.category, | |
| "severity": severity, | |
| "stats": self.stats, | |
| "issues": self.issues, | |
| "passed": passed, | |
| } | |
| self.ws.save_meta({"audit": result}) | |
| return result | |
| def _split_into_sections(self, text: str) -> dict[str, str]: | |
| """Split study text into sections based on ## headings.""" | |
| sections = {} | |
| lines = text.split("\n") | |
| current_key = None | |
| current_lines = [] | |
| for line in lines: | |
| stripped = line.strip() | |
| if stripped.startswith("## "): | |
| # Save previous section | |
| if current_key: | |
| sections[current_key] = "\n".join(current_lines) | |
| # Identify section | |
| header_text = stripped[3:].strip() | |
| current_key = None | |
| for key, marker in SECTION_MARKERS: | |
| if marker in header_text: | |
| current_key = key | |
| break | |
| if current_key: | |
| current_lines = [line] | |
| else: | |
| current_lines = [line] | |
| elif current_key: | |
| current_lines.append(line) | |
| # Save last section | |
| if current_key: | |
| sections[current_key] = "\n".join(current_lines) | |
| return sections | |
| def _audit_sections(self, full_text: str, sections: dict[str, str]): | |
| """Per-section citation density and quality analysis.""" | |
| section_stats = {} | |
| for key, marker in SECTION_MARKERS: | |
| sec_text = sections.get(key, "") | |
| if not sec_text: | |
| section_stats[key] = { | |
| "name": SECTION_NAMES.get(key, key), | |
| "exists": False, | |
| "words": 0, | |
| "citations": 0, | |
| "paragraphs": 0, | |
| "paragraphs_without_citation": 0, | |
| "density": 0.0, | |
| } | |
| continue | |
| # Count citations in this section | |
| citations = len(re.findall(CITATION_INLINE_PATTERN, sec_text)) | |
| # Count paragraphs | |
| body = re.split(r"\n##\s*(?:قائمة\s+)?المصادر", sec_text)[0].strip() | |
| paragraphs = [ | |
| p.strip() | |
| for p in body.split("\n\n") | |
| if p.strip() | |
| and not p.strip().startswith("#") | |
| and p.strip() not in ["---", "***", "___"] | |
| and not p.strip().startswith("|") | |
| ] | |
| paras_without = 0 | |
| for p in paragraphs: | |
| if not re.search(CITATION_INLINE_PATTERN, p): | |
| paras_without += 1 | |
| words = len(sec_text.split()) | |
| density = 1 - (paras_without / len(paragraphs)) if paragraphs else 0.0 | |
| section_stats[key] = { | |
| "name": SECTION_NAMES.get(key, key), | |
| "exists": True, | |
| "words": words, | |
| "citations": citations, | |
| "paragraphs": len(paragraphs), | |
| "paragraphs_without_citation": paras_without, | |
| "density": round(density, 2), | |
| } | |
| # Flag sections with low citation density | |
| if paragraphs and density < CITATION_DENSITY_TARGET and key != "masadir": | |
| self.issues.append( | |
| { | |
| "type": "section_low_density", | |
| "severity": "error", | |
| "message": f"كثافة التوثيق منخفضة في {SECTION_NAMES.get(key, key)}: {density:.0%} ({paras_without}/{len(paragraphs)} فقرة بلا توثيق)", | |
| "section": key, | |
| } | |
| ) | |
| # Flag sections that are too short | |
| if key != "masadir" and 0 < words < 200: | |
| self.issues.append( | |
| { | |
| "type": "section_too_short", | |
| "severity": "warning", | |
| "message": f"القسم {SECTION_NAMES.get(key, key)} قصير جداً: {words} كلمة", | |
| "section": key, | |
| } | |
| ) | |
| self.stats["sections"] = section_stats | |
| def _check_raw_utilization(self, text: str, draft: dict): | |
| """Check what percentage of raw material links appear in the study — per section.""" | |
| study_sections = self._split_into_sections(text) | |
| section_util = {} | |
| all_raw_links = set() | |
| for sec_key, sec_data in draft.get("by_section", {}).items(): | |
| sec_links = set() | |
| for item in sec_data.get("results", []): | |
| link = item.get("link", "") | |
| if link: | |
| sec_links.add(link) | |
| all_raw_links.add(link) | |
| if not sec_links: | |
| section_util[sec_key] = {"total": 0, "used": 0, "ratio": 0.0} | |
| continue | |
| sec_text = study_sections.get(sec_key, "") | |
| sec_study_links = set( | |
| re.findall(r"https://shamela\.ws/book/\d+/\d+", sec_text) | |
| ) | |
| used = sec_links & sec_study_links | |
| ratio = len(used) / len(sec_links) if sec_links else 0.0 | |
| section_util[sec_key] = { | |
| "total": len(sec_links), | |
| "used": len(used), | |
| "ratio": round(ratio, 2), | |
| } | |
| self.stats["section_utilization"] = section_util | |
| # Global stats for backward compatibility | |
| all_study_links = set(re.findall(r"https://shamela\.ws/book/\d+/\d+", text)) | |
| global_used = all_raw_links & all_study_links | |
| global_util = len(global_used) / len(all_raw_links) if all_raw_links else 0.0 | |
| self.stats["raw_links_total"] = len(all_raw_links) | |
| self.stats["raw_links_used"] = len(global_used) | |
| self.stats["raw_utilization"] = round(global_util, 2) | |
| # Average per-section utilization for warning threshold | |
| non_empty = [s for s in section_util.values() if s["total"] > 0] | |
| avg_util = ( | |
| sum(s["ratio"] for s in non_empty) / len(non_empty) if non_empty else 0.0 | |
| ) | |
| if avg_util < 0.3 and non_empty: | |
| self.issues.append( | |
| { | |
| "type": "low_raw_utilization", | |
| "severity": "warning", | |
| "message": f"نسبة استخدام المادة الخام منخفضة: {avg_util:.0%} (متوسط عبر الأقسام، {len(global_used)}/{len(all_raw_links)} رابط مستخدم)", | |
| "details": f"تم استخدام {len(global_used)} رابط من أصل {len(all_raw_links)} في المادة الخام", | |
| } | |
| ) | |
| def _check_cross_references(self, text: str, draft: dict = None): | |
| """Validate that narrator names and book titles in the study match the raw material.""" | |
| if not draft: | |
| return | |
| # Collect known narrators from draft | |
| known_narrators = set() | |
| for sec_data in draft.get("by_section", {}).values(): | |
| for item in sec_data.get("results", []): | |
| narrator = item.get("narrator", "").strip() | |
| if narrator: | |
| known_narrators.add(narrator) | |
| # Collect known book titles from draft | |
| known_books = set() | |
| for sec_data in draft.get("by_section", {}).values(): | |
| for item in sec_data.get("results", []): | |
| book = item.get("book", "").strip() | |
| if book: | |
| known_books.add(book) | |
| # Check for narrator names mentioned in study but not in raw material | |
| # Arabic name pattern: 2-4 words of Arabic chars | |
| study_narrators = set() | |
| for match in re.finditer( | |
| r"(?:الراوي|الرواة|يقول|قال)\s+([^\s,،]+(?:\s+[^\s,،]+){0,2})", text | |
| ): | |
| name = match.group(1).strip() | |
| if len(name) > 3 and not any( | |
| stop in name for stop in ["هذا", "هذه", "ذلك", "تلك", "الذي", "التي"] | |
| ): | |
| study_narrators.add(name) | |
| unvalidated_narrators = [] | |
| for name in study_narrators: | |
| # Check if this name appears in any known narrator | |
| found = any(name in kn or kn in name for kn in known_narrators) | |
| if not found and len(name) > 5: | |
| unvalidated_narrators.append(name) | |
| if unvalidated_narrators: | |
| self.stats["unvalidated_narrators"] = len(unvalidated_narrators) | |
| self.issues.append( | |
| { | |
| "type": "unvalidated_narrators", | |
| "severity": "warning", | |
| "message": f"{len(unvalidated_narrators)} أسماء رواة في الدراسة لا تتطابق مع المادة الخام", | |
| "details": unvalidated_narrators[:5], | |
| } | |
| ) | |
| self.stats["known_narrators"] = len(known_narrators) | |
| self.stats["known_books"] = len(known_books) | |
| def _count_citations(self, text: str): | |
| numbered_pattern = r"\(\d+\)" | |
| inline_citations = re.findall(CITATION_INLINE_PATTERN, text) | |
| numbered_citations = re.findall(numbered_pattern, text) | |
| total_inline = len(inline_citations) | |
| total_numbered = len(numbered_citations) | |
| self.stats["inline_citations"] = total_inline | |
| self.stats["numbered_citations"] = total_numbered | |
| self.stats["total_citations"] = total_inline + total_numbered | |
| def _check_citation_density(self, text: str): | |
| # Exclude the bibliography section (and anything after it) from paragraph density checks | |
| body_text = re.split(r"\n##\s*(?:قائمة\s+)?المصادر", text)[0].strip() | |
| paragraphs = [ | |
| p.strip() | |
| for p in body_text.split("\n\n") | |
| if p.strip() | |
| and not p.strip().startswith("#") | |
| and p.strip() not in ["---", "***", "___"] | |
| and not p.strip().startswith("|") | |
| ] | |
| total_paragraphs = len(paragraphs) | |
| paragraphs_without_citation = 0 | |
| for p in paragraphs: | |
| if not re.search(CITATION_INLINE_PATTERN, p): | |
| paragraphs_without_citation += 1 | |
| self.stats["total_paragraphs"] = total_paragraphs | |
| self.stats["paragraphs_without_citation"] = paragraphs_without_citation | |
| if total_paragraphs > 0: | |
| density = 1 - (paragraphs_without_citation / total_paragraphs) | |
| self.stats["citation_density"] = round(density, 2) | |
| if density < CITATION_DENSITY_TARGET: | |
| self.issues.append( | |
| { | |
| "type": "low_citation_density", | |
| "severity": "error", | |
| "message": f"كثافة التوثيق منخفضة: {density:.0%} (المطلوب ≥ {CITATION_DENSITY_TARGET:.0%})", | |
| "details": f"{paragraphs_without_citation}/{total_paragraphs} فقرات بلا توثيق", | |
| } | |
| ) | |
| if self.stats["total_citations"] < MIN_TOTAL_CITATIONS: | |
| self.issues.append( | |
| { | |
| "type": "insufficient_citations", | |
| "severity": "error", | |
| "message": f"عدد التواقيع غير كافٍ: {self.stats['total_citations']} (الحد الأدنى: {MIN_TOTAL_CITATIONS})", | |
| } | |
| ) | |
| def _check_hallucinated_links(self, text: str): | |
| links = re.findall(r"https://shamela\.ws/book/\d+/\d+", text) | |
| valid_links = self.ws.raw_dir / "search_results.json" | |
| if valid_links.exists(): | |
| draft = json.loads(valid_links.read_text(encoding="utf-8")) | |
| known_links = set() | |
| for sec_data in draft.get("by_section", {}).values(): | |
| for item in sec_data.get("results", []): | |
| known_links.add(item.get("link", "")) | |
| hallucinated = [l for l in links if l not in known_links] | |
| self.stats["total_links"] = len(links) | |
| self.stats["hallucinated_links"] = len(hallucinated) | |
| if hallucinated: | |
| self.issues.append( | |
| { | |
| "type": "hallucinated_links", | |
| "severity": "error", | |
| "message": f"تم اكتشاف {len(hallucinated)} روابط مختلقة", | |
| "details": hallucinated[:5], | |
| } | |
| ) | |
| def _check_structure(self, text: str): | |
| required = [ | |
| "تمهيد", | |
| "المبحث الأول", | |
| "المبحث الثاني", | |
| "المبحث الثالث", | |
| "المبحث الرابع", | |
| "المبحث الخامس", | |
| "قائمة المصادر", | |
| ] | |
| missing = [] | |
| for section in required: | |
| found = any( | |
| section in line | |
| for line in text.split("\n") | |
| if line.strip().startswith("#") | |
| ) | |
| if not found: | |
| missing.append(section) | |
| self.stats["required_sections"] = len(required) | |
| self.stats["found_sections"] = len(required) - len(missing) | |
| if missing: | |
| self.issues.append( | |
| { | |
| "type": "missing_sections", | |
| "severity": "error", | |
| "message": f"أقسام مفقودة: {missing}", | |
| } | |
| ) | |
| def _check_length(self, text: str): | |
| words = len(text.split()) | |
| chars = len(text) | |
| lines = len(text.split("\n")) | |
| self.stats["words"] = words | |
| self.stats["chars"] = chars | |
| self.stats["lines"] = lines | |
| if words < MIN_STUDY_WORDS: | |
| self.issues.append( | |
| { | |
| "type": "too_short", | |
| "severity": "warning", | |
| "message": f"الدراسة قصيرة جداً: {words} كلمة (المطلوب ≥ {MIN_STUDY_WORDS})", | |
| } | |
| ) | |
| def _check_language(self, text: str): | |
| english_words = re.findall(r"\b[a-zA-Z]{3,}\b", text) | |
| exclude_words = { | |
| "sha", | |
| "html", | |
| "http", | |
| "com", | |
| "https", | |
| "shamela", | |
| "book", | |
| "url", | |
| "www", | |
| "org", | |
| "net", | |
| } | |
| english_words = [w for w in english_words if w.lower() not in exclude_words] | |
| self.stats["english_words"] = len(english_words) | |
| if english_words: | |
| self.issues.append( | |
| { | |
| "type": "english_content", | |
| "severity": "warning", | |
| "message": f"تم اكتشاف {len(english_words)} كلمة إنجليزية", | |
| "details": english_words[:10], | |
| } | |
| ) | |
| def print_report(self): | |
| result = self.audit( | |
| (self.ws.processed_dir / "study.md").read_text(encoding="utf-8") | |
| if (self.ws.processed_dir / "study.md").exists() | |
| else "" | |
| ) | |
| print(f"\n{'=' * 60}") | |
| print(f"تقرير Audit لللفظ: {self.term}") | |
| print(f"{'=' * 60}") | |
| print(f"الحالة: {'✅ PASS' if result['passed'] else '❌ FAIL'}") | |
| print(f"{'=' * 60}") | |
| print(f"\n📊 الإحصائيات العامة:") | |
| for k, v in result["stats"].items(): | |
| if k != "sections": | |
| print(f" {k}: {v}") | |
| # Print section-level stats | |
| sections = result["stats"].get("sections", {}) | |
| if sections: | |
| print(f"\n📊 تفاصيل الأقسام:") | |
| for key, sec in sections.items(): | |
| status = "✅" if sec["exists"] else "❌" | |
| density_str = f"{sec['density']:.0%}" if sec["exists"] else "N/A" | |
| print( | |
| f" {status} {sec['name']}: {sec['words']} كلمة | {sec['citations']} توثيق | كثافة: {density_str}" | |
| ) | |
| if result["issues"]: | |
| print(f"\n⚠️ المشاكل ({len(result['issues'])}):") | |
| for issue in result["issues"]: | |
| icon = "❌" if issue["severity"] == "error" else "⚠️" | |
| print(f" {icon} [{issue['type']}] {issue['message']}") | |
| if "details" in issue: | |
| for d in ( | |
| issue["details"][:3] | |
| if isinstance(issue["details"], list) | |
| else [issue["details"]] | |
| ): | |
| print(f" → {d}") | |
| else: | |
| print(f"\n✅ لا توجد مشاكل!") | |
| print(f"\n{'=' * 60}") | |
| return result | |
| def format_audit_report_markdown(audit_res: dict) -> str: | |
| stats = audit_res.get("stats", {}) | |
| severity = audit_res.get("severity", "PASS") | |
| severity_map = { | |
| "PASS": "ناجح (مطابق للمنهجية)", | |
| "WARN": "تنبيه (ملاحظات منهجية)", | |
| "FAIL": "راسب (مخالف للمنهجية)", | |
| } | |
| severity_arabic = severity_map.get(severity, severity) | |
| total_citations = stats.get("total_citations", 0) | |
| density = stats.get("citation_density", 0.0) | |
| density_percent = int(density * 100) | |
| words = stats.get("words", 0) | |
| total_paragraphs = stats.get("total_paragraphs", 0) | |
| paragraphs_without_citation = stats.get("paragraphs_without_citation", 0) | |
| found_sections = stats.get("found_sections", 0) | |
| required_sections = stats.get("required_sections", 7) | |
| issues = audit_res.get("issues", []) | |
| if not issues: | |
| issues_list = "* ✓ لا توجد أي مخالفات منهجية أو لغوية." | |
| else: | |
| issues_list = "" | |
| for issue in issues: | |
| icon = "❌" if issue.get("severity") == "error" else "⚠️" | |
| issues_list += ( | |
| f"\n- {icon} **[{issue.get('type')}]** {issue.get('message')}" | |
| ) | |
| if "details" in issue: | |
| details = issue["details"] | |
| if isinstance(details, list): | |
| for d in details: | |
| issues_list += f"\n - {d}" | |
| else: | |
| issues_list += f"\n - {details}" | |
| # Section-level breakdown — no tables, use bullet lists | |
| sections = stats.get("sections", {}) | |
| section_rows = "" | |
| if sections: | |
| section_rows = "\n### تفاصيل الأقسام:\n" | |
| for key, sec in sections.items(): | |
| status = "✓" if sec.get("exists") else "✗" | |
| density_val = f"{sec.get('density', 0):.0%}" if sec.get("exists") else "غير متاح" | |
| section_rows += ( | |
| f"- **{sec.get('name', key)}**: الحالة {status}، " | |
| f"الكلمات {sec.get('words', 0)}، " | |
| f"التوثيقات {sec.get('citations', 0)}، " | |
| f"الكثافة {density_val}\n" | |
| ) | |
| # Raw utilization | |
| raw_util = stats.get("raw_utilization") | |
| raw_section = "" | |
| if raw_util is not None: | |
| raw_used = stats.get("raw_links_used", 0) | |
| raw_total = stats.get("raw_links_total", 0) | |
| raw_section = f"\n### استخدام المادة الخام:\n- نسبة الاستخدام: {raw_util:.0%} ({raw_used}/{raw_total} رابط)\n" | |
| # Per-section breakdown — bullet list | |
| sec_util = stats.get("section_utilization", {}) | |
| if sec_util: | |
| for key, su in sec_util.items(): | |
| name = SECTION_NAMES.get(key, key) | |
| ratio_str = f"{su['ratio']:.0%}" | |
| raw_section += f"- **{name}**: المجموع {su['total']}، المستخدم {su['used']}، النسبة {ratio_str}\n" | |
| report_md = f""" | |
| ## ملحق: تقرير الجودة المنهجية والأكاديمية | |
| * **حالة التدقيق**: {severity_arabic} | |
| * **إجمالي التوثيقات**: {total_citations} | |
| * **كثافة التوثيق في الفقرات**: {density_percent}% | |
| * **عدد الكلمات**: {words} كلمة | |
| * **عدد الفقرات**: {total_paragraphs} فقرة | |
| * **الفقرات الخالية من التوثيق**: {paragraphs_without_citation} فقرة | |
| * **الأقسام المكتملة**: {found_sections} من {required_sections} | |
| {section_rows}{raw_section} | |
| ### تفاصيل ملاحظات التدقيق المنهجي: | |
| {issues_list} | |
| """ | |
| return report_md | |
| if __name__ == "__main__": | |
| import sys | |
| if len(sys.argv) < 3: | |
| print("Usage: python3 study_audit.py <term> <category>") | |
| print(" category: tadeel or jarh") | |
| sys.exit(1) | |
| term = sys.argv[1] | |
| category = sys.argv[2] | |
| auditor = StudyAuditor(term, category) | |
| auditor.print_report() | |