الوحدة 10 — مشروع: مساعد مستندات كامل من الاستخراج إلى الواجهة
الوحدات التسع السابقة كانت لبنات مستقلّة. هذه الوحدة تُجمّعها في مشروع واحد قابل للتشغيل: أخذ 300 مستند مؤسّسي، بناء فهرسها، تشغيل واجهة استفسار، وتحليل الأعطال المتوقّعة. الفيل الأحمر يكتمل هنا.
بنية المشروع
مجلَّد بسيط قابل للتوسّع:
assistant-procedures/
├── data/
│ ├── raw/ # المستندات الأصلية
│ └── processed/ # نصّ مستخرَج مع ما وراء البيانات
├── index/ # قاعدة البيانات المتّجهية
├── src/
│ ├── ingest.py # استخراج + تقطيع + تضمين + تخزين
│ ├── retriever.py # هجين + إعادة ترتيب
│ ├── prompt.py # بناء التعليمة
│ ├── pipeline.py # الاستدعاء الكامل
│ ├── auth.py # فلاتر الأذونات
│ └── cache.py # التخزين المؤقّت
├── api/
│ └── main.py # FastAPI
├── ui/
│ └─ ─ app.py # Streamlit
├── eval/
│ ├── questions.yml # مجموعة الأسئلة الموصوفة
│ └── run.py # تشغيل التقييم
└── requirements.txt
فصل src/ عن api/ وui/ يُتيح إعادة استعمال الخطّ نفسه في نصوص برمجية دفعية (تحديث الفهرس ليلًا) أو من CLI.
سلسلة الاستيعاب الكاملة
# src/ingest.py
from pathlib import Path
import hashlib
from unstructured.partition.auto import partition
from langchain.text_splitter import RecursiveCharacterTextSplitter
from sentence_transformers import SentenceTransformer
import chromadb
EMBEDDING_MODEL = SentenceTransformer("intfloat/multilingual-e5-large")
CLIENT = chromadb.PersistentClient(path="./index")
COLLECTION = CLIENT.get_or_create_collection(
name="procedures", metadata={"hnsw:space": "cosine"},
)
def ingest_document(path: Path, access_level: str = "public"):
doc_hash = hashlib.sha256(path.read_bytes()).hexdigest()
if is_up_to_date(path, doc_hash):
return
delete_chunks_for_source(str(path))
elements = partition(filename=str(path))
text = "\n\n".join(el.text for el in elements if el.text)
splitter = RecursiveCharacterTextSplitter(
chunk_size=400, chunk_overlap=60,
separators=["\n\n", "\n", "。", ".", "؟", "!", "،", " "],
)
chunks = splitter.split_text(text)
embeddings = EMBEDDING_MODEL.encode(
[f"passage: {c}" for c in chunks], normalize_embeddings=True,
)
COLLECTION.add(
ids=[f"{path.stem}-{i:04d}" for i in range(len(chunks))],
documents=chunks,
embeddings=embeddings.tolist(),
metadatas=[
{
"source": str(path),
"access_level": access_level,
"hash": doc_hash,
} for _ in chunks
],
)
def ingest_folder(folder: Path):
for path in folder.rglob("*"):
if path.suffix.lower() in [".pdf", ".docx", ".html", ".pptx"]:
level = detect_access_level(path) # حسب المسار مثلًا
ingest_document(path, access_level=level)
سلسلة الاستفسار
# src/pipeline.py
from src.retriever import hybrid_retrieve, rerank
from src.prompt import build_prompt
from src.cache import get_cached_answer, cache_answer
from src.auth import user_access_filter
def answer(question: str, user):
cached = get_cached_answer(question, user)
if cached:
return cached
filters = user_access_filter(user)
candidates = hybrid_retrieve(question, filters=filters, k=20)
top = rerank(question, candidates, k=5)
messages = build_prompt(question, top)
response = LLM.invoke(messages)
result = {
"answer": response.content,
"citations": [
{"source": p.metadata["source"], "page": p.metadata.get("page")}
for p in top
],
}
cache_answer(question, user, result, ttl=3600)
return result