diff --git a/.github/workflows/chatbot.yml b/.github/workflows/chatbot.yml index f5c8268..b44f96c 100644 --- a/.github/workflows/chatbot.yml +++ b/.github/workflows/chatbot.yml @@ -12,22 +12,39 @@ on: jobs: unit-tests: runs-on: ubuntu-latest + defaults: run: working-directory: chatbot + steps: - uses: actions/checkout@v4 with: submodules: true + + + + - uses: actions/setup-python@v5 with: python-version: "3.12" cache: pip cache-dependency-path: chatbot/requirements.txt + + + - name: Install dependencies + run: | + pip install -r requirements.txt + + - name: Run unit and API tests + env: + JWT_SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }} +======= - name: Install dependencies run: | pip install -r requirements.txt - name: Run unit and API tests env: JWT_SECRET_KEY: "4d7d6b996c2e65e73b6c03056b45d675ab121910a01e49eb2a39b74aa6f8bae9" + main run: pytest rag-engine/tests -q --tb=short --ignore=rag-engine/tests/test_injection_real_pdf.py \ No newline at end of file diff --git a/ai-ml/embedding/chroma_store.py b/ai-ml/embedding/chroma_store.py index dce4ed2..69bc111 100644 --- a/ai-ml/embedding/chroma_store.py +++ b/ai-ml/embedding/chroma_store.py @@ -1,14 +1,30 @@ """ -Shared ChromaDB store for the ingestion pipeline. -Uses the same persistent path, collection name, and embedding model -as the chatbot (Team Mu) so ingested chunks are retrievable by it. +Shared ChromaDB store — the single canonical content store for both +the ingestion pipeline and quiz generation. + +[P0-1 fix] Previously, quiz generation queried Pinecone + MongoDB +(see the old embedder.py) while ingestion wrote here. The two never +overlapped, so newly ingested content was invisible to quiz +generation. This file is now the ONLY storage path for both: +ingestion calls store_chunks() (unchanged), and quiz generation's +Embedder.search() calls query_chunks() (new) instead of touching +Pinecone/Mongo at all. + +Also fixes a subtler bug: this module used to load its own separate +SentenceTransformer instance with default (non-normalized) output, +while embedding/model.py's shared model normalizes its vectors. Two +different embedding configs writing into the same cosine-similarity +space would have produced quietly wrong nearest-neighbor results. +Both reads and writes now go through the one shared, normalized +model in model.py. """ import os from pathlib import Path import chromadb from dotenv import load_dotenv -from sentence_transformers import SentenceTransformer + +from embedding.model import get_embedding_model load_dotenv(dotenv_path=Path(__file__).parent.parent.parent / ".env") @@ -17,16 +33,6 @@ r"C:\Dev\QuantumLearningWorkspace\shared_chroma_data", ) DEFAULT_COLLECTION_NAME = "study_chunks" -DEFAULT_MODEL_NAME = "all-MiniLM-L6-v2" - -_model = None - - -def get_embedding_model() -> SentenceTransformer: - global _model - if _model is None: - _model = SentenceTransformer(DEFAULT_MODEL_NAME) - return _model def get_collection(name: str = DEFAULT_COLLECTION_NAME, path: str = None): @@ -45,7 +51,7 @@ def store_chunks(chunks: list[dict], user_id: str, document_id: str, title: str) return 0 collection = get_collection() - model = get_embedding_model() + model = get_embedding_model() # shared, normalized model — same one queries use ids = [f"{document_id}_chunk{c['chunk_index']}" for c in chunks] documents = [c["text"] for c in chunks] @@ -58,7 +64,7 @@ def store_chunks(chunks: list[dict], user_id: str, document_id: str, title: str) } for c in chunks ] - embeddings = model.encode(documents).tolist() + embeddings = model.encode(documents) # list[list[float]], already normalized collection.upsert( ids=ids, @@ -67,3 +73,65 @@ def store_chunks(chunks: list[dict], user_id: str, document_id: str, title: str) metadatas=metadatas, ) return len(chunks) + + +def query_chunks( + query_text: str, + top_k: int = 5, + user_id: str = None, + document_id: str = None, +) -> list: + """ + [P0-1 new] Embed a query and return the top_k nearest chunks from + the shared collection, in the shape quiz_service.py expects: + [{"score": float, "text": str, "title": str, "metadata": dict}, ...] + + user_id / document_id, if given, are applied as an exact-match + metadata filter (ChromaDB's `where`). Both default to None (no + filter) today, which preserves current unscoped behavior — this + is deliberately plumbed through now so P0-3 (user-scoped quiz + retrieval) can pass user_id here without another storage change. + """ + collection = get_collection() + model = get_embedding_model() + + where = {} + if user_id is not None: + where["user_id"] = user_id + if document_id is not None: + where["document_id"] = document_id + + query_embedding = model.encode(query_text)[0] + + results = collection.query( + query_embeddings=[query_embedding], + n_results=top_k, + where=where or None, + ) + + ids = results.get("ids", [[]])[0] + documents = results.get("documents", [[]])[0] + metadatas = results.get("metadatas", [[]])[0] + distances = results.get("distances", [[]])[0] + + output = [] + for i in range(len(ids)): + meta = metadatas[i] or {} + output.append({ + "score": distances[i], + "text": documents[i], + "title": meta.get("document", ""), + "metadata": meta, + }) + return output + + +def delete_chunks(document_id: str) -> None: + """ + [P0-1 new] Remove all chunks belonging to a document from the + shared collection. Chroma's delete-by-filter is idempotent + (deleting a non-existent id/filter is a no-op), which is also + what P0-5's purge endpoint will need. + """ + collection = get_collection() + collection.delete(where={"document_id": document_id}) \ No newline at end of file diff --git a/ai-ml/embedding/embedder.py b/ai-ml/embedding/embedder.py index 5e2361b..f38fdf5 100644 --- a/ai-ml/embedding/embedder.py +++ b/ai-ml/embedding/embedder.py @@ -1,129 +1,107 @@ """ -Orchestrates the full embedding pipeline: - - document (common ingestion schema) - -> chunk (chunker.py) - -> embed (model.py, free local sentence-transformers) - -> store full chunk text + metadata in MongoDB (source of truth) - -> store vector + small pointer metadata in Pinecone (for search) - -MongoDB holds the full text because Pinecone metadata has size/type -limits and isn't meant for large text blobs. Pinecone only stores the -vector plus a small pointer (chunk id + a few filterable fields) so a -similarity search can be resolved back to the full chunk via MongoDB. +Orchestrates document embedding + the search interface quiz +generation uses. + +[P0-1 fix] This used to run its own parallel storage path — full +chunk text in MongoDB (source of truth) + vectors in Pinecone (for +search) — completely separate from ingestion's shared ChromaDB. That +meant anything ingested via ingestion/main.py was invisible to +QuizService.generate_quiz_from_topic(), since it searched Pinecone. + +Everything now reads and writes the ONE shared ChromaDB collection +via chroma_store.py — the same collection ingestion already writes +to. No more Mongo/Pinecone clients, no more second indexing path. """ import uuid -from pymongo import MongoClient -import certifi -from embedding.config import settings from embedding.chunker import chunk_document -from embedding.model import get_embedding_model -from embedding.vector_store import PineconeVectorStore +from embedding.chroma_store import store_chunks, query_chunks, delete_chunks import argparse import requests class Embedder: - def __init__(self, model=None, vector_store=None, mongo_client=None): - self.model = model or get_embedding_model() - self.vector_store = vector_store or PineconeVectorStore(dimension=self.model.dimension()) + def __init__(self): + # No client setup needed here anymore — chroma_store.py owns + # the one shared collection + embedding model as module-level + # helpers, created fresh per call (see get_collection()). + pass - self._mongo_client = mongo_client or MongoClient( - settings.mongodb_uri, tlsCAFile=certifi.where() - ) - self._db = self._mongo_client[settings.mongodb_db] - self._collection = self._db[settings.mongodb_collection] - - def embed_document(self, document: dict) -> dict: + def embed_document(self, document: dict, user_id: str = "unknown") -> dict: """ document: common ingestion schema { "source_type": ..., "title": ..., "text": ..., "metadata": {...} } - Chunks it, embeds every chunk, saves full text to MongoDB, and - upserts vectors to Pinecone. Returns a small summary dict. + Chunks it and stores it in the shared ChromaDB collection. + Returns a small summary dict. + + Note: the normal ingestion request path (POST /ingest/pdf etc.) + goes through ingestion/main.py's own _chunk_and_store(), which + calls chroma_store.store_chunks() directly and doesn't use + this method. This method exists for standalone/manual use + (see the CLI at the bottom of this file) and now writes to the + exact same store, so both paths stay consistent. """ chunks = chunk_document(document) if not chunks: return {"document_id": None, "chunks_stored": 0} - texts = [c["text"] for c in chunks] - vectors = self.model.encode(texts) - document_id = str(uuid.uuid4()) - mongo_docs = [] - pinecone_vectors = [] - - for chunk, vector in zip(chunks, vectors): - chunk_id = f"{document_id}_{chunk['chunk_index']}" - - mongo_docs.append({ - "_id": chunk_id, - "document_id": document_id, - "chunk_index": chunk["chunk_index"], - "text": chunk["text"], - "title": chunk["title"], - "source_type": chunk["source_type"], - "metadata": chunk["metadata"], - }) - - pinecone_vectors.append({ - "id": chunk_id, - "values": vector, - "metadata": { - "document_id": document_id, - "chunk_index": chunk["chunk_index"], - "title": chunk["title"], - "source_type": chunk["source_type"], - }, - }) - - if mongo_docs: - self._collection.insert_many(mongo_docs) - - self.vector_store.upsert(pinecone_vectors) - - return {"document_id": document_id, "chunks_stored": len(mongo_docs)} - - def search(self, query: str, top_k: int = 5) -> list: + stored_count = store_chunks( + chunks=chunks, + user_id=user_id, + document_id=document_id, + title=document.get("title", ""), + ) + return {"document_id": document_id, "chunks_stored": stored_count} + + def search( + self, + query: str, + top_k: int = 5, + user_id: str = None, + document_id: str = None, + ) -> list: """ - Embeds the query, finds the nearest chunks in Pinecone, then - resolves each match back to its full text stored in MongoDB. + Searches the shared ChromaDB collection — the same store + ingestion writes to, so newly ingested content is immediately + queryable here (P0-1's Definition of Done). + + user_id / document_id are optional scoping filters, passed + straight through to chroma_store.query_chunks(). Left as None + by default (unscoped), matching current QuizService behavior; + P0-3 is what wires the caller-side enforcement of these. """ - query_vector = self.model.encode(query)[0] - matches = self.vector_store.query(query_vector, top_k=top_k) - - results = [] - for match in matches: - chunk_id = match["id"] if isinstance(match, dict) else match.id - score = match["score"] if isinstance(match, dict) else match.score - - mongo_doc = self._collection.find_one({"_id": chunk_id}) - if mongo_doc: - results.append({ - "score": score, - "text": mongo_doc["text"], - "title": mongo_doc["title"], - "source_type": mongo_doc["source_type"], - "metadata": mongo_doc["metadata"], - }) - return results - - def delete_document(self, document_id: str, chunk_count: int): - """Remove a document's chunks from both MongoDB and Pinecone.""" - ids = [f"{document_id}_{i}" for i in range(chunk_count)] - self._collection.delete_many({"document_id": document_id}) - self.vector_store.delete(ids) - - def close(self): - self._mongo_client.close() + return query_chunks(query, top_k=top_k, user_id=user_id, document_id=document_id) + def delete_document(self, document_id: str, chunk_count: int = None): + """ + Remove a document's chunks from the shared store. + chunk_count is no longer needed (Chroma deletes by + document_id metadata filter, not by reconstructing chunk ids) + but is accepted for backward compatibility with existing callers. + """ + delete_chunks(document_id) + def close(self): + """ + No-op now — chroma_store.py doesn't hold a persistent client + connection open between calls, so there's nothing to close. + Kept so existing callers (e.g. the CLI below) don't need to + change. + """ + pass -INGESTION_BASE_URL = "http://127.0.0.1:8000" +INGESTION_BASE_URL = "http://127.0.0.1:8001" +# NOTE: this was previously "http://127.0.0.1:8000", which is Mu's +# confirmed port, not Lambda ingestion's. Per the confirmed port +# scheme (Mu=8000, Pluto=5000, Lambda ingestion=8001, Lambda +# quiz=8002), 8001 is correct here. Flagging in case this was +# intentional for some other reason — worth a quick sanity check +# against P1-6's verification pass. def _fetch_from_ingestion(pdf=None, youtube=None, article=None) -> dict: @@ -171,7 +149,7 @@ def _fetch_from_ingestion(pdf=None, youtube=None, article=None) -> dict: } embedder = Embedder() - summary = embedder.embed_document(document) + summary = embedder.embed_document(document, user_id="cli-test-user") print("Embedded:", summary) query = document.get("title") or document.get("text", "")[:50] @@ -179,4 +157,4 @@ def _fetch_from_ingestion(pdf=None, youtube=None, article=None) -> dict: for r in results: print(f"[{r['score']:.3f}] {r['text'][:100]}...") - embedder.close() + embedder.close() \ No newline at end of file diff --git a/ai-ml/quiz_generator/app/auth.py b/ai-ml/quiz_generator/app/auth.py new file mode 100644 index 0000000..3085146 --- /dev/null +++ b/ai-ml/quiz_generator/app/auth.py @@ -0,0 +1,58 @@ +""" +[Contract v1, Section 10 — Quiz Security] JWT authentication for +quiz endpoints. + +Matches the scheme documented in docs/api-contracts.md for Mu's +/ask: HS256, JWT_SECRET_KEY env var, "Authorization: Bearer ", +identity read from the token's `sub` claim (the user's login email, +per Contract v1 Section 2). No user_id is ever trusted from a +request body or header directly — only from a verified token. + +Requires PyJWT (`pip install pyjwt`) — add it to requirements.txt +if it isn't already there (Mu's service already depends on it for +the same purpose, so it's likely already in the root/shared +requirements somewhere; worth checking before assuming it needs +adding here too). +""" + +import os + +import jwt +from fastapi import Header, HTTPException + +JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "") +JWT_ALGORITHM = "HS256" + + +def get_current_user_id(authorization: str = Header(default=None)) -> str: + """ + FastAPI dependency. Verifies the Authorization header and returns + the authenticated user's identity from the JWT's `sub` claim. + + Failure modes match Contract v1 / Mu's documented /ask behavior + exactly, so error handling is consistent across services: + - missing header -> 403 "Not authenticated" + - invalid/expired token -> 401 "Could not validate credentials." + - server missing the secret -> 500 "Server is not configured with a JWT secret." + """ + if not JWT_SECRET_KEY: + raise HTTPException( + status_code=500, + detail="Server is not configured with a JWT secret.", + ) + + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException(status_code=403, detail="Not authenticated") + + token = authorization.split(" ", 1)[1] + + try: + payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM]) + except jwt.PyJWTError: + raise HTTPException(status_code=401, detail="Could not validate credentials.") + + user_id = payload.get("sub") + if not user_id: + raise HTTPException(status_code=401, detail="Could not validate credentials.") + + return user_id \ No newline at end of file diff --git a/ai-ml/quiz_generator/app/main.py b/ai-ml/quiz_generator/app/main.py index 9c912a3..365cc72 100644 --- a/ai-ml/quiz_generator/app/main.py +++ b/ai-ml/quiz_generator/app/main.py @@ -7,15 +7,14 @@ Interactive docs: http://127.0.0.1:8002/docs """ -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, HTTPException, Depends +from fastapi.middleware.cors import CORSMiddleware from quiz_generator.app.models.api_models import GenerateQuizRequest, GenerateQuizResponse from quiz_generator.app.services.quiz_service import QuizService -from fastapi.middleware.cors import CORSMiddleware - +from quiz_generator.app.auth import get_current_user_id app = FastAPI(title="StudyMind Quiz API — Team Lambda") -from fastapi.middleware.cors import CORSMiddleware app.add_middleware( CORSMiddleware, @@ -41,7 +40,15 @@ def health_check(): @app.post("/generate-quiz", response_model=GenerateQuizResponse) -def generate_quiz_endpoint(body: GenerateQuizRequest) -> GenerateQuizResponse: +def generate_quiz_endpoint( + body: GenerateQuizRequest, + user_id: str = Depends(get_current_user_id), +) -> GenerateQuizResponse: + """ + [Contract v1, Section 10] Requires "Authorization: Bearer ". + user_id is derived from the verified token (never from client + input) and used to scope retrieval to this user's own content only. + """ service = get_service() valid_types = {"mcq", "true_false", "fill_blank", "short_answer"} @@ -55,6 +62,7 @@ def generate_quiz_endpoint(body: GenerateQuizRequest) -> GenerateQuizResponse: result = service.generate_quiz_from_topic( topic=body.topic, question_type=body.quiz_type, + user_id=user_id, number_of_questions=body.question_count, ) except ValueError as exc: diff --git a/ai-ml/quiz_generator/app/models/api_models.py b/ai-ml/quiz_generator/app/models/api_models.py index 58f813a..99dde3b 100644 --- a/ai-ml/quiz_generator/app/models/api_models.py +++ b/ai-ml/quiz_generator/app/models/api_models.py @@ -2,7 +2,13 @@ class GenerateQuizRequest(BaseModel): - """Request body for POST /generate-quiz.""" + """ + Request body for POST /generate-quiz. + + No user_id field here — per Contract v1 Section 2, identity is + never trusted from client input. The authenticated user_id comes + from the verified JWT (see auth.py's get_current_user_id). + """ topic: str = Field(..., min_length=1, description="Topic to generate the quiz from.") question_count: int = Field( @@ -15,7 +21,14 @@ class GenerateQuizRequest(BaseModel): class GenerateQuizResponse(BaseModel): - """Response body for POST /generate-quiz.""" + """ + Response body for POST /generate-quiz. + + Per Contract v1 Section 10, the existing question/answer split + stays: "questions" never includes correct answers; "answers" is + a separate list matched by question_id for the caller (Pluto) to + store and grade against. This matches docs/api-contracts.md. + """ success: bool message: str diff --git a/ai-ml/quiz_generator/app/services/quiz_service.py b/ai-ml/quiz_generator/app/services/quiz_service.py index 48cbed0..ae505e4 100644 --- a/ai-ml/quiz_generator/app/services/quiz_service.py +++ b/ai-ml/quiz_generator/app/services/quiz_service.py @@ -15,7 +15,9 @@ class QuizService: """ def __init__(self): - # Initialize the Embedder to search Pinecone and MongoDB + # [P0-1 fix] Embedder now searches the shared ChromaDB store — + # the same one ingestion writes to — instead of the old, + # disconnected Pinecone + MongoDB path. self.embedder = Embedder() # Initialize the AI Generators @@ -30,22 +32,33 @@ def generate_quiz_from_topic( self, topic: str, question_type: str, + user_id: str, number_of_questions: int = 5, difficulty: str = "medium", top_k: int = 3, ) -> dict: """ RAG PIPELINE (The Bridge in action): - 1. Search: Finds relevant text chunks in the Vector Store. - 2. Retrieve: Fetches full text from MongoDB. - 3. Generate: Feeds that specific text to the LLM to get structured questions. - - Returns a dict with two SEPARATE lists: - - "questions": what the frontend shows the user (no answers included) - - "answers": question_id -> correct answer, used only at grading time + 1. Search: Finds relevant text chunks in the shared ChromaDB store, + scoped to the authenticated user (Contract v1 Section 10 — + quiz retrieval MUST be filtered by user_id). + 2. Generate: Feeds that specific text to the LLM to get structured questions. + + [Contract v1] Per Section 10, the existing question/answer split + stays as-is: this returns BOTH a "questions" list (no answers) + and an "answers" list (matched by question_id) — the shape + Pluto's proxy already expects and stores server-side for + grading. Do not strip "answers" from this return value; that + was an earlier draft fix that turned out to contradict the + signed-off contract. + + user_id is REQUIRED and must come from a verified JWT + (see auth.py) — never from client input — so the search below + can never cross into another user's content. """ - # A. Search for context based on the user's topic - search_results = self.embedder.search(topic, top_k=top_k) + # A. Search for context based on the user's topic, scoped to + # this user's own content only. + search_results = self.embedder.search(topic, top_k=top_k, user_id=user_id) if not search_results: return { @@ -96,7 +109,9 @@ def _split_questions_and_answers(questions: list) -> dict: """ Splits a list of Question objects into two separate lists: one safe to send to the frontend before submission (no answers), - and one kept server-side for grading. + and one kept server-side for grading. Per Contract v1 Section 10, + this split — and its presence in the /generate-quiz response — + is the intended, documented design; it is not being removed. """ public_questions = [] answers = [] diff --git a/ai-ml/weak_topic_detection/.gitignore b/ai-ml/weak_topic_detection/.gitignore new file mode 100644 index 0000000..ca87034 --- /dev/null +++ b/ai-ml/weak_topic_detection/.gitignore @@ -0,0 +1,11 @@ +__pycache__/ +*.py[cod] +*$py.class + +.venv/ +venv/ +env/ + +.env + +.pytest_cache/ \ No newline at end of file diff --git a/ai-ml/weak_topic_detection/README.md b/ai-ml/weak_topic_detection/README.md new file mode 100644 index 0000000..908cf10 --- /dev/null +++ b/ai-ml/weak_topic_detection/README.md @@ -0,0 +1,34 @@ +# Weak Topic Detection + +A module that analyzes quiz results to identify topics where a learner is performing weakly. + +## Features + +- Loads quiz results from JSON data +- Calculates accuracy for each topic +- Identifies weak topics using an accuracy threshold +- Requires a minimum number of attempts before evaluating a topic +- Provides a service and API interface +- Validates quiz-result data +- Includes automated tests + +## Current Configuration + +- Weak topic threshold: 60% +- Minimum attempts required: 3 + +## Project Structure + +```text +weak_topic_detection/ +├── app/ +│ ├── api/ +│ ├── detectors/ +│ ├── models/ +│ ├── services/ +│ ├── utils/ +│ ├── validators/ +│ └── config.py +├── data/ +│ └── quiz_results.json +└── tests/ \ No newline at end of file diff --git a/ai-ml/weak_topic_detection/app/api/weak_topic_api.py b/ai-ml/weak_topic_detection/app/api/weak_topic_api.py new file mode 100644 index 0000000..33eece7 --- /dev/null +++ b/ai-ml/weak_topic_detection/app/api/weak_topic_api.py @@ -0,0 +1,12 @@ +from app.services.weak_topic_service import WeakTopicService + + +class WeakTopicAPI: + """Interface for accessing weak-topic detection.""" + + def __init__(self): + self.service = WeakTopicService() + + def get_weak_topics(self): + """Return the weak topics detected from quiz results.""" + return self.service.get_weak_topics() \ No newline at end of file diff --git a/ai-ml/weak_topic_detection/app/config.py b/ai-ml/weak_topic_detection/app/config.py new file mode 100644 index 0000000..0646d66 --- /dev/null +++ b/ai-ml/weak_topic_detection/app/config.py @@ -0,0 +1,2 @@ +WEAK_TOPIC_THRESHOLD = 0.60 +MIN_TOPIC_ATTEMPTS = 3 \ No newline at end of file diff --git a/ai-ml/weak_topic_detection/app/detectors/weak_topic_detector.py b/ai-ml/weak_topic_detection/app/detectors/weak_topic_detector.py new file mode 100644 index 0000000..4198212 --- /dev/null +++ b/ai-ml/weak_topic_detection/app/detectors/weak_topic_detector.py @@ -0,0 +1,57 @@ +from collections import defaultdict +from app.models.quiz_result import QuizResult + + +class WeakTopicDetector: + """ + Detects weak topics based on quiz performance. + """ + + def __init__(self, weak_threshold: float = 0.60, min_attempts: int = 3): + self.weak_threshold = weak_threshold + self.min_attempts = min_attempts + + def detect(self, results: list[QuizResult]) -> list[dict]: + """ + Identify weak topics from quiz results. + + A topic is considered weak when: + - It has at least the minimum number of attempts. + - Its accuracy is below the weak-topic threshold. + """ + + topic_results = defaultdict(list) + + # Group quiz results by topic + for result in results: + topic_results[result.topic].append(result) + + weak_topics = [] + + # Calculate accuracy for each topic + for topic, topic_attempts in topic_results.items(): + + total_attempts = len(topic_attempts) + + # Ignore topics with insufficient attempts + if total_attempts < self.min_attempts: + continue + + correct_answers = sum( + result.is_correct for result in topic_attempts + ) + + accuracy = correct_answers / total_attempts + + # Identify weak topics + if accuracy < self.weak_threshold: + weak_topics.append({ + "topic": topic, + "accuracy": round(accuracy * 100, 2), + "attempts": total_attempts + }) + + # Weakest topics first + weak_topics.sort(key=lambda item: item["accuracy"]) + + return weak_topics \ No newline at end of file diff --git a/ai-ml/weak_topic_detection/app/models/quiz_result.py b/ai-ml/weak_topic_detection/app/models/quiz_result.py new file mode 100644 index 0000000..3688f32 --- /dev/null +++ b/ai-ml/weak_topic_detection/app/models/quiz_result.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass + + +@dataclass +class QuizResult: + user_id: str + question_id: str + topic: str + selected_answer: str + correct_answer: str + is_correct: bool + date_taken: str \ No newline at end of file diff --git a/ai-ml/weak_topic_detection/app/services/weak_topic_service.py b/ai-ml/weak_topic_detection/app/services/weak_topic_service.py new file mode 100644 index 0000000..f95b6d8 --- /dev/null +++ b/ai-ml/weak_topic_detection/app/services/weak_topic_service.py @@ -0,0 +1,37 @@ +from app.detectors.weak_topic_detector import WeakTopicDetector +from app.models.quiz_result import QuizResult +from app.utils.data_loader import load_json_data +from app.config import WEAK_TOPIC_THRESHOLD, MIN_TOPIC_ATTEMPTS + + +class WeakTopicService: + """ + Loads quiz results and uses WeakTopicDetector + to identify weak topics. + """ + + def __init__( + self, + data_file: str = "data/quiz_results.json", + weak_threshold: float = WEAK_TOPIC_THRESHOLD, +min_attempts: int = MIN_TOPIC_ATTEMPTS, + ): + self.data_file = data_file + self.detector = WeakTopicDetector( + weak_threshold=weak_threshold, + min_attempts=min_attempts, + ) + + def load_results(self) -> list[QuizResult]: + """Load quiz results from the JSON file.""" + + data = load_json_data(self.data_file) + + return [QuizResult(**item) for item in data] + + def get_weak_topics(self) -> list[dict]: + """Return weak topics detected from quiz results.""" + + results = self.load_results() + + return self.detector.detect(results) \ No newline at end of file diff --git a/ai-ml/weak_topic_detection/app/utils/data_loader.py b/ai-ml/weak_topic_detection/app/utils/data_loader.py new file mode 100644 index 0000000..d544ca1 --- /dev/null +++ b/ai-ml/weak_topic_detection/app/utils/data_loader.py @@ -0,0 +1,11 @@ +import json +from pathlib import Path + + +def load_json_data(file_path: str) -> list[dict]: + """Load quiz-result data from a JSON file.""" + + path = Path(file_path) + + with path.open("r", encoding="utf-8") as file: + return json.load(file) \ No newline at end of file diff --git a/ai-ml/weak_topic_detection/app/validators/quiz_result_validator.py b/ai-ml/weak_topic_detection/app/validators/quiz_result_validator.py new file mode 100644 index 0000000..fb3d48f --- /dev/null +++ b/ai-ml/weak_topic_detection/app/validators/quiz_result_validator.py @@ -0,0 +1,36 @@ +from app.models.quiz_result import QuizResult + + +class QuizResultValidator: + """Validates quiz-result data before processing.""" + + REQUIRED_FIELDS = { + "user_id", + "question_id", + "topic", + "selected_answer", + "correct_answer", + "is_correct", + "date_taken", + } + + @classmethod + def validate(cls, result: QuizResult) -> bool: + """Return True when a quiz result contains valid required data.""" + + if not result.user_id: + return False + + if not result.question_id: + return False + + if not result.topic: + return False + + if not isinstance(result.is_correct, bool): + return False + + if not result.date_taken: + return False + + return True \ No newline at end of file diff --git a/ai-ml/weak_topic_detection/commands.txt b/ai-ml/weak_topic_detection/commands.txt new file mode 100644 index 0000000..2775d36 --- /dev/null +++ b/ai-ml/weak_topic_detection/commands.txt @@ -0,0 +1,23 @@ +WEAK TOPIC DETECTION - COMMANDS +========================================== + +1. MAIN WEAK TOPIC DETECTION + +python -m tests.test_weak_topic_service + +2. WEAK TOPIC DETECTOR TEST + +python -m tests.test_weak_topic_detector + +3. QUIZ RESULT VALIDATION TEST + +python -m tests.test_quiz_result_validator + +4. API TEST + +python -c "from app.api.weak_topic_api import WeakTopicAPI; print(WeakTopicAPI().get_weak_topics())" + +5. RUN ALL PYTEST TESTS + +python -m pytest tests + diff --git a/ai-ml/weak_topic_detection/data/quiz_results.json b/ai-ml/weak_topic_detection/data/quiz_results.json new file mode 100644 index 0000000..d6be79d --- /dev/null +++ b/ai-ml/weak_topic_detection/data/quiz_results.json @@ -0,0 +1,276 @@ +[ + { + "user_id": "user_001", + "question_id": "ml_q001", + "topic": "Machine Learning", + "selected_answer": "Supervised Learning", + "correct_answer": "Unsupervised Learning", + "is_correct": false, + "date_taken": "2026-08-01" + }, + { + "user_id": "user_001", + "question_id": "ml_q002", + "topic": "Machine Learning", + "selected_answer": "Classification", + "correct_answer": "Classification", + "is_correct": true, + "date_taken": "2026-08-02" + }, + { + "user_id": "user_001", + "question_id": "ml_q003", + "topic": "Machine Learning", + "selected_answer": "Regression", + "correct_answer": "Clustering", + "is_correct": false, + "date_taken": "2026-08-03" + }, + { + "user_id": "user_001", + "question_id": "ml_q004", + "topic": "Machine Learning", + "selected_answer": "Decision Tree", + "correct_answer": "Decision Tree", + "is_correct": true, + "date_taken": "2026-08-04" + }, + { + "user_id": "user_001", + "question_id": "ml_q005", + "topic": "Machine Learning", + "selected_answer": "K-Means", + "correct_answer": "Linear Regression", + "is_correct": false, + "date_taken": "2026-08-05" + }, + { + "user_id": "user_001", + "question_id": "ml_q006", + "topic": "Machine Learning", + "selected_answer": "Training Data", + "correct_answer": "Training Data", + "is_correct": true, + "date_taken": "2026-08-06" + }, + + { + "user_id": "user_001", + "question_id": "dl_q001", + "topic": "Deep Learning", + "selected_answer": "Neural Network", + "correct_answer": "Neural Network", + "is_correct": true, + "date_taken": "2026-08-01" + }, + { + "user_id": "user_001", + "question_id": "dl_q002", + "topic": "Deep Learning", + "selected_answer": "CNN", + "correct_answer": "RNN", + "is_correct": false, + "date_taken": "2026-08-02" + }, + { + "user_id": "user_001", + "question_id": "dl_q003", + "topic": "Deep Learning", + "selected_answer": "Backpropagation", + "correct_answer": "Backpropagation", + "is_correct": true, + "date_taken": "2026-08-03" + }, + { + "user_id": "user_001", + "question_id": "dl_q004", + "topic": "Deep Learning", + "selected_answer": "Pooling", + "correct_answer": "Dropout", + "is_correct": false, + "date_taken": "2026-08-04" + }, + { + "user_id": "user_001", + "question_id": "dl_q005", + "topic": "Deep Learning", + "selected_answer": "Gradient Descent", + "correct_answer": "Gradient Descent", + "is_correct": true, + "date_taken": "2026-08-05" + }, + { + "user_id": "user_001", + "question_id": "dl_q006", + "topic": "Deep Learning", + "selected_answer": "Overfitting", + "correct_answer": "Regularization", + "is_correct": false, + "date_taken": "2026-08-06" + }, + + { + "user_id": "user_001", + "question_id": "nlp_q001", + "topic": "Natural Language Processing", + "selected_answer": "Tokenization", + "correct_answer": "Tokenization", + "is_correct": true, + "date_taken": "2026-08-01" + }, + { + "user_id": "user_001", + "question_id": "nlp_q002", + "topic": "Natural Language Processing", + "selected_answer": "Sentiment Analysis", + "correct_answer": "Sentiment Analysis", + "is_correct": true, + "date_taken": "2026-08-02" + }, + { + "user_id": "user_001", + "question_id": "nlp_q003", + "topic": "Natural Language Processing", + "selected_answer": "Stemming", + "correct_answer": "Stemming", + "is_correct": true, + "date_taken": "2026-08-03" + }, + { + "user_id": "user_001", + "question_id": "nlp_q004", + "topic": "Natural Language Processing", + "selected_answer": "Named Entity Recognition", + "correct_answer": "Named Entity Recognition", + "is_correct": true, + "date_taken": "2026-08-04" + }, + { + "user_id": "user_001", + "question_id": "nlp_q005", + "topic": "Natural Language Processing", + "selected_answer": "Machine Translation", + "correct_answer": "Machine Translation", + "is_correct": true, + "date_taken": "2026-08-05" + }, + { + "user_id": "user_001", + "question_id": "nlp_q006", + "topic": "Natural Language Processing", + "selected_answer": "Word Embeddings", + "correct_answer": "Text Classification", + "is_correct": false, + "date_taken": "2026-08-06" + }, + + { + "user_id": "user_001", + "question_id": "cv_q001", + "topic": "Computer Vision", + "selected_answer": "Image Classification", + "correct_answer": "Image Classification", + "is_correct": true, + "date_taken": "2026-08-01" + }, + { + "user_id": "user_001", + "question_id": "cv_q002", + "topic": "Computer Vision", + "selected_answer": "Object Detection", + "correct_answer": "Object Detection", + "is_correct": true, + "date_taken": "2026-08-02" + }, + { + "user_id": "user_001", + "question_id": "cv_q003", + "topic": "Computer Vision", + "selected_answer": "Image Segmentation", + "correct_answer": "Image Segmentation", + "is_correct": true, + "date_taken": "2026-08-03" + }, + { + "user_id": "user_001", + "question_id": "cv_q004", + "topic": "Computer Vision", + "selected_answer": "CNN", + "correct_answer": "CNN", + "is_correct": true, + "date_taken": "2026-08-04" + }, + { + "user_id": "user_001", + "question_id": "cv_q005", + "topic": "Computer Vision", + "selected_answer": "Edge Detection", + "correct_answer": "Edge Detection", + "is_correct": true, + "date_taken": "2026-08-05" + }, + { + "user_id": "user_001", + "question_id": "cv_q006", + "topic": "Computer Vision", + "selected_answer": "Object Detection", + "correct_answer": "Image Segmentation", + "is_correct": false, + "date_taken": "2026-08-06" + }, + + { + "user_id": "user_001", + "question_id": "py_q001", + "topic": "Python Programming", + "selected_answer": "List", + "correct_answer": "List", + "is_correct": true, + "date_taken": "2026-08-01" + }, + { + "user_id": "user_001", + "question_id": "py_q002", + "topic": "Python Programming", + "selected_answer": "Dictionary", + "correct_answer": "Dictionary", + "is_correct": true, + "date_taken": "2026-08-02" + }, + { + "user_id": "user_001", + "question_id": "py_q003", + "topic": "Python Programming", + "selected_answer": "for loop", + "correct_answer": "for loop", + "is_correct": true, + "date_taken": "2026-08-03" + }, + { + "user_id": "user_001", + "question_id": "py_q004", + "topic": "Python Programming", + "selected_answer": "Function", + "correct_answer": "Function", + "is_correct": true, + "date_taken": "2026-08-04" + }, + { + "user_id": "user_001", + "question_id": "py_q005", + "topic": "Python Programming", + "selected_answer": "Tuple", + "correct_answer": "Tuple", + "is_correct": true, + "date_taken": "2026-08-05" + }, + { + "user_id": "user_001", + "question_id": "py_q006", + "topic": "Python Programming", + "selected_answer": "Exception Handling", + "correct_answer": "Exception Handling", + "is_correct": true, + "date_taken": "2026-08-06" + } +] \ No newline at end of file diff --git a/ai-ml/weak_topic_detection/requirements.txt b/ai-ml/weak_topic_detection/requirements.txt new file mode 100644 index 0000000..55b033e --- /dev/null +++ b/ai-ml/weak_topic_detection/requirements.txt @@ -0,0 +1 @@ +pytest \ No newline at end of file diff --git a/ai-ml/weak_topic_detection/tests/test_quiz_result_validator.py b/ai-ml/weak_topic_detection/tests/test_quiz_result_validator.py new file mode 100644 index 0000000..b4b0246 --- /dev/null +++ b/ai-ml/weak_topic_detection/tests/test_quiz_result_validator.py @@ -0,0 +1,31 @@ +from app.models.quiz_result import QuizResult +from app.validators.quiz_result_validator import QuizResultValidator + + +def main(): + valid_result = QuizResult( + user_id="user_001", + question_id="q001", + topic="Machine Learning", + selected_answer="A", + correct_answer="B", + is_correct=False, + date_taken="2026-08-01", + ) + + invalid_result = QuizResult( + user_id="", + question_id="q002", + topic="Machine Learning", + selected_answer="A", + correct_answer="B", + is_correct=False, + date_taken="2026-08-01", + ) + + print("Valid result:", QuizResultValidator.validate(valid_result)) + print("Invalid result:", QuizResultValidator.validate(invalid_result)) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/ai-ml/weak_topic_detection/tests/test_weak_topic_api.py b/ai-ml/weak_topic_detection/tests/test_weak_topic_api.py new file mode 100644 index 0000000..69db9b1 --- /dev/null +++ b/ai-ml/weak_topic_detection/tests/test_weak_topic_api.py @@ -0,0 +1,15 @@ +from app.api.weak_topic_api import WeakTopicAPI + + +def test_get_weak_topics(): + api = WeakTopicAPI() + + result = api.get_weak_topics() + + assert isinstance(result, list) + assert len(result) > 0 + + for topic in result: + assert "topic" in topic + assert "accuracy" in topic + assert "attempts" in topic \ No newline at end of file diff --git a/ai-ml/weak_topic_detection/tests/test_weak_topic_detector.py b/ai-ml/weak_topic_detection/tests/test_weak_topic_detector.py new file mode 100644 index 0000000..8ccb1bd --- /dev/null +++ b/ai-ml/weak_topic_detection/tests/test_weak_topic_detector.py @@ -0,0 +1,75 @@ +from app.detectors.weak_topic_detector import WeakTopicDetector +from app.models.quiz_result import QuizResult + + +def main(): + results = [ + # 3 attempts — should be evaluated + QuizResult( + user_id="user_001", + question_id="q1", + topic="Machine Learning", + selected_answer="A", + correct_answer="B", + is_correct=False, + date_taken="2026-08-01", + ), + QuizResult( + user_id="user_001", + question_id="q2", + topic="Machine Learning", + selected_answer="B", + correct_answer="B", + is_correct=True, + date_taken="2026-08-02", + ), + QuizResult( + user_id="user_001", + question_id="q3", + topic="Machine Learning", + selected_answer="A", + correct_answer="B", + is_correct=False, + date_taken="2026-08-03", + ), + + # Only 2 attempts — should be ignored + QuizResult( + user_id="user_001", + question_id="q4", + topic="Python", + selected_answer="A", + correct_answer="B", + is_correct=False, + date_taken="2026-08-01", + ), + QuizResult( + user_id="user_001", + question_id="q5", + topic="Python", + selected_answer="A", + correct_answer="B", + is_correct=False, + date_taken="2026-08-02", + ), + ] + + detector = WeakTopicDetector( + weak_threshold=0.60, + min_attempts=3, + ) + + weak_topics = detector.detect(results) + + print("\n========== Minimum Attempt Rule Test ==========\n") + + for topic in weak_topics: + print( + f"{topic['topic']} - " + f"Accuracy: {topic['accuracy']}% - " + f"Attempts: {topic['attempts']}" + ) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/ai-ml/weak_topic_detection/tests/test_weak_topic_service.py b/ai-ml/weak_topic_detection/tests/test_weak_topic_service.py new file mode 100644 index 0000000..cb548ce --- /dev/null +++ b/ai-ml/weak_topic_detection/tests/test_weak_topic_service.py @@ -0,0 +1,24 @@ +from app.services.weak_topic_service import WeakTopicService + + +def main(): + service = WeakTopicService() + + weak_topics = service.get_weak_topics() + + print("\n========== Weak Topic Detection Output ==========\n") + + if not weak_topics: + print("No weak topics detected.") + return + + for index, topic in enumerate(weak_topics, start=1): + print( + f"{index}. {topic['topic']} - " + f"Accuracy: {topic['accuracy']}% - " + f"Attempts: {topic['attempts']}" + ) + + +if __name__ == "__main__": + main() \ No newline at end of file