diff --git a/.env.example b/.env.example index 64d012c..016c337 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,7 @@ + feature/knowledge-graph +GROQ_API_KEY=your-groq-api-key +JWT_SECRET_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0X3VzZXIifQ.kr2UKjrAiugP8-X_4wCC_rlFIKXnk_Vh4DCjNFxQHsQ + GROQ_API_KEY=groq_api_key_here # Email OTP Verification (Optional for local dev) @@ -5,3 +9,4 @@ SMTP_HOST=smtp.gmail.com SMTP_PORT=587 SMTP_USER=your_email@gmail.com SMTP_PASSWORD=your_16_character_app_password + diff --git a/ai-ml/.env.example b/ai-ml/.env.example index 2540840..0b24192 100644 --- a/ai-ml/.env.example +++ b/ai-ml/.env.example @@ -1,4 +1,4 @@ -GROQ_API_KEY=groq_api_key_here +GROQ_API_KEY=your-groq-api-key # Pinecone (RAG vector database) PINECONE_API_KEY=your-pinecone-api-key diff --git a/ai-ml/.gitignore b/ai-ml/.gitignore index 0728cf7..dca7d73 100644 Binary files a/ai-ml/.gitignore and b/ai-ml/.gitignore differ diff --git a/ai-ml/embedding/embedder.py b/ai-ml/embedding/embedder.py index f38fdf5..99afe8b 100644 --- a/ai-ml/embedding/embedder.py +++ b/ai-ml/embedding/embedder.py @@ -43,6 +43,12 @@ def embed_document(self, document: dict, user_id: str = "unknown") -> dict: 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. + + [Task 5, pending] Auto-updating the knowledge graph on upload + needs to be hooked in here AND in ingestion/main.py's + _chunk_and_store() (the actual path real uploads use) — + intentionally not added yet, since the ingestion-side change + is being held off for review before touching shared code. """ chunks = chunk_document(document) if not chunks: @@ -55,6 +61,7 @@ def embed_document(self, document: dict, user_id: str = "unknown") -> dict: document_id=document_id, title=document.get("title", ""), ) + return {"document_id": document_id, "chunks_stored": stored_count} def search( @@ -99,9 +106,7 @@ def close(self): # 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. +# quiz=8002), 8001 is correct here. def _fetch_from_ingestion(pdf=None, youtube=None, article=None) -> dict: diff --git a/ai-ml/ingestion/main.py b/ai-ml/ingestion/main.py index 6cf672e..9f598f0 100644 --- a/ai-ml/ingestion/main.py +++ b/ai-ml/ingestion/main.py @@ -17,6 +17,13 @@ caller can never claim to be another user. This closes the gap identified in NV-2: previously these endpoints had no authentication at all, unlike quiz_generator's endpoints, which were already correct. + +[Task 5] After storing a new document's chunks, the knowledge graph +is incrementally updated (new document compared only against the +user's existing documents, not a full rebuild) — so /graph reflects +new uploads immediately without a manual /graph/rebuild call. Wrapped +defensively so a graph-update failure never breaks the actual +ingestion request the user is waiting on. """ from __future__ import annotations import shutil @@ -48,6 +55,20 @@ class URLRequest(BaseModel): # the verified JWT, never from client-supplied request data. +def _update_knowledge_graph(user_id: str, document_id: str) -> None: + """ + [Task 5] Best-effort incremental graph update. Any failure here + (LLM call down, graph module misconfigured, etc.) is swallowed so + it never blocks or breaks the ingestion response — the user's + upload should succeed even if the graph update doesn't. + """ + try: + from knowledge_graph.app.services.graph_service import GraphService + GraphService().add_document(user_id, document_id) + except Exception: + pass + + def _chunk_and_store(result: dict, user_id: str) -> dict: """Shared helper: chunk an ingested document and store it in ChromaDB.""" document_id = str(uuid.uuid4()) @@ -58,6 +79,9 @@ def _chunk_and_store(result: dict, user_id: str) -> dict: document_id=document_id, title=result.get("title", ""), ) + + _update_knowledge_graph(user_id, document_id) + return { "document_id": document_id, "title": result.get("title", ""), @@ -72,7 +96,6 @@ def _chunk_and_store(result: dict, user_id: str) -> dict: async def ingest_pdf_endpoint( file: UploadFile = File(...), user_id: str = Depends(get_current_user_id), - # user_id="test_user", ): if not file.filename or not file.filename.lower().endswith(".pdf"): raise HTTPException(status_code=400, detail="File must be a PDF") @@ -100,8 +123,7 @@ async def ingest_pdf_endpoint( @app.post("/ingest/youtube") async def ingest_youtube_endpoint( payload: URLRequest, - user_id: str = Depends(get_current_user_id), - # user_id = "test_user", + user_id: str = Depends(get_current_user_id), ): try: result = ingest_youtube(payload.url) @@ -111,7 +133,7 @@ async def ingest_youtube_endpoint( error_msg = str(e) status_code = 400 if "YouTube" in error_msg else 500 raise HTTPException(status_code=status_code, detail=error_msg) - + return {**result, **storage_info} diff --git a/ai-ml/knowledge_graph/app/builders/document_graph_builder.py b/ai-ml/knowledge_graph/app/builders/document_graph_builder.py index 95109cd..2131b82 100644 --- a/ai-ml/knowledge_graph/app/builders/document_graph_builder.py +++ b/ai-ml/knowledge_graph/app/builders/document_graph_builder.py @@ -13,6 +13,7 @@ from knowledge_graph.app.utils.topic_labeler import generate_label from knowledge_graph.app.validators.graph_validators import validate_documents_have_vectors from knowledge_graph.app.models.graph_edge import GraphEdge +from knowledge_graph.app.utils.relationship_classifier import classify_relationship def get_user_documents(user_id: str) -> dict: @@ -47,10 +48,6 @@ def get_user_documents(user_id: str) -> dict: def build_document_graph(user_id: str) -> list: - """ - Returns a list of GraphEdge.to_dict() for this user's - document-to-document relationships. - """ docs = validate_documents_have_vectors(get_user_documents(user_id)) doc_vectors = { @@ -59,14 +56,24 @@ def build_document_graph(user_id: str) -> list: } edges = [] + seen_pairs = set() # NEW — prevents duplicate edges, either direction + for (id_a, vec_a), (id_b, vec_b) in combinations(doc_vectors.items(), 2): + if id_a == id_b: # NEW — explicit self-link guard, defensive + continue + + pair_key = frozenset((id_a, id_b)) # NEW — order-independent identity + if pair_key in seen_pairs: # NEW + continue + seen_pairs.add(pair_key) # NEW + score = cosine_similarity(vec_a, vec_b) if score >= SIMILARITY_THRESHOLD_DOCUMENT: - # label from a sample of each document's text, not the full text sample_a = " ".join(docs[id_a]["texts"][:2]) sample_b = " ".join(docs[id_b]["texts"][:2]) edge = GraphEdge( + user_id=user_id, source_id=id_a, target_id=id_b, @@ -75,7 +82,8 @@ def build_document_graph(user_id: str) -> list: source_title=docs[id_a]["title"], target_title=docs[id_b]["title"], label=generate_label(sample_a, sample_b), + relationship_type=classify_relationship(sample_a, sample_b), # NEW ) edges.append(edge.to_dict()) - return edges + return edges \ No newline at end of file diff --git a/ai-ml/knowledge_graph/app/builders/topic_graph_builder.py b/ai-ml/knowledge_graph/app/builders/topic_graph_builder.py index 07367df..dc246b3 100644 --- a/ai-ml/knowledge_graph/app/builders/topic_graph_builder.py +++ b/ai-ml/knowledge_graph/app/builders/topic_graph_builder.py @@ -10,6 +10,7 @@ from knowledge_graph.app.utils.topic_labeler import generate_label from knowledge_graph.app.models.graph_edge import GraphEdge from knowledge_graph.app.builders.document_graph_builder import get_user_documents +from knowledge_graph.app.utils.relationship_classifier import classify_relationship def build_topic_graph(user_id: str, max_chunks: int = 200) -> list: @@ -17,13 +18,10 @@ def build_topic_graph(user_id: str, max_chunks: int = 200) -> list: Returns a list of GraphEdge.to_dict() for chunk-level relationships. max_chunks caps how many of the user's chunks are compared, since - comparing every pair is O(n^2) — protects against runaway cost for - users with very large amounts of content. Chunks beyond the cap - are simply not included in this pass. + comparing every pair is O(n^2). """ docs = get_user_documents(user_id) - # flatten into a single list of (chunk_id, vector, text, title) chunks = [] for doc_id, data in docs.items(): for i, (vector, text) in enumerate(zip(data["vectors"], data["texts"])): @@ -37,10 +35,21 @@ def build_topic_graph(user_id: str, max_chunks: int = 200) -> list: chunks = chunks[:max_chunks] edges = [] + seen_pairs = set() # NEW — prevents duplicate edges, either direction + for a, b in combinations(chunks, 2): + if a["chunk_id"] == b["chunk_id"]: # NEW — explicit self-link guard + continue + + pair_key = frozenset((a["chunk_id"], b["chunk_id"])) # NEW + if pair_key in seen_pairs: # NEW + continue + seen_pairs.add(pair_key) # NEW + score = cosine_similarity(a["vector"], b["vector"]) if score >= SIMILARITY_THRESHOLD_TOPIC: edge = GraphEdge( + user_id=user_id, source_id=a["chunk_id"], target_id=b["chunk_id"], @@ -49,7 +58,8 @@ def build_topic_graph(user_id: str, max_chunks: int = 200) -> list: source_title=a["title"], target_title=b["title"], label=generate_label(a["text"], b["text"]), + relationship_type=classify_relationship(a["text"], b["text"]), # NEW ) edges.append(edge.to_dict()) - return edges + return edges \ No newline at end of file diff --git a/ai-ml/knowledge_graph/app/models/graph_edge.py b/ai-ml/knowledge_graph/app/models/graph_edge.py index 4cc8705..981791f 100644 --- a/ai-ml/knowledge_graph/app/models/graph_edge.py +++ b/ai-ml/knowledge_graph/app/models/graph_edge.py @@ -10,11 +10,12 @@ class GraphEdge: user_id: str source_id: str target_id: str - node_type: str # "document" or "topic" — matches the nodes it connects + node_type: str # "document" or "topic" similarity: float source_title: str target_title: str - label: Optional[str] = None # short human-readable reason for the connection + label: Optional[str] = None # keyword-based explanation (existing) + relationship_type: str = "related_to" # NEW — placeholder until LLM classification (task 3) fills this in with real types like "prerequisite_of", "example_of", etc. def to_dict(self) -> dict: - return asdict(self) + return asdict(self) \ No newline at end of file diff --git a/ai-ml/knowledge_graph/app/services/graph_service.py b/ai-ml/knowledge_graph/app/services/graph_service.py index 9478b52..99ae68b 100644 --- a/ai-ml/knowledge_graph/app/services/graph_service.py +++ b/ai-ml/knowledge_graph/app/services/graph_service.py @@ -1,6 +1,6 @@ """ Main integration interface for the knowledge graph module — what -api/graph_routes.py and any other module calls. +api/graph_routes.py, ingestion, and any other module calls. user_id is accepted as an explicit parameter on every method here (not sourced from a request/token internally), so this class stays @@ -11,15 +11,24 @@ from knowledge_graph.app.validators.graph_validators import validate_user_id from knowledge_graph.app.builders.document_graph_builder import build_document_graph, get_user_documents from knowledge_graph.app.builders.topic_graph_builder import build_topic_graph -from knowledge_graph.app.storage import graph_store +from knowledge_graph.app.utils.vectorizer import get_document_vector +from knowledge_graph.app.utils.similarity import cosine_similarity +from knowledge_graph.app.utils.topic_labeler import generate_label +from knowledge_graph.app.utils.relationship_classifier import classify_relationship +from knowledge_graph.app.utils.definition_generator import generate_definition +from knowledge_graph.app.config import SIMILARITY_THRESHOLD_DOCUMENT +from knowledge_graph.app.models.graph_edge import GraphEdge +from knowledge_graph.app.storage import graph_store, node_store class GraphService: def build_graph(self, user_id: str, include_topics: bool = True) -> dict: """ - Builds (or rebuilds) this user's full graph and persists it. - Returns a summary dict, not the full graph — call get_graph() - to read it back. + Full rebuild: recomputes ALL of this user's document (and + optionally topic) edges from scratch, replacing whatever was + stored before. Use add_document() instead for incrementally + updating the graph after a single new upload — much cheaper, + since it avoids re-classifying every existing pair. """ validate_user_id(user_id) @@ -38,6 +47,73 @@ def build_graph(self, user_id: str, include_topics: bool = True) -> dict: "topic_edges_created": topic_edge_count, } + def add_document(self, user_id: str, document_id: str) -> dict: + """ + [Task 5] Incrementally updates the graph after a single new + document is embedded — compares only the new document against + existing ones, rather than re-comparing every pair in the + user's whole history. Avoids re-running LLM classification on + unchanged pairs, so cost scales with new content, not the + user's full document count. + + Safe to call even if document_id has no chunks yet, or if the + user has no other documents to compare against — returns + edges_created: 0 in either case rather than raising. + """ + validate_user_id(user_id) + + docs = get_user_documents(user_id) + if document_id not in docs or not docs[document_id]["vectors"]: + return {"user_id": user_id, "document_id": document_id, "edges_created": 0} + + new_vector = get_document_vector(docs[document_id]["vectors"]) + new_title = docs[document_id]["title"] + new_sample = " ".join(docs[document_id]["texts"][:2]) + + # [Task 4] Generate this document's definition once, when it's + # first added — not on every /graph read, to avoid an LLM call + # per node per request. + full_text = " ".join(docs[document_id]["texts"]) + definition = generate_definition(full_text) + node_store.save_node_metadata(user_id, document_id, definition) + + existing_edges = graph_store.get_edges(user_id, node_type="document") + new_edges = [] + + for other_id, other_data in docs.items(): + if other_id == document_id: + continue + + already_linked = any( + {e["source_id"], e["target_id"]} == {document_id, other_id} + for e in existing_edges + ) + if already_linked: + continue + + other_vector = get_document_vector(other_data["vectors"]) + score = cosine_similarity(new_vector, other_vector) + + if score >= SIMILARITY_THRESHOLD_DOCUMENT: + other_sample = " ".join(other_data["texts"][:2]) + edge = GraphEdge( + user_id=user_id, + source_id=document_id, + target_id=other_id, + node_type="document", + similarity=round(score, 4), + source_title=new_title, + target_title=other_data["title"], + label=generate_label(new_sample, other_sample), + relationship_type=classify_relationship(new_sample, other_sample), + ) + new_edges.append(edge.to_dict()) + + if new_edges: + graph_store.append_edges(user_id, new_edges, node_type="document") + + return {"user_id": user_id, "document_id": document_id, "edges_created": len(new_edges)} + def get_graph(self, user_id: str) -> dict: """ Returns { "nodes": [...], "edges": [...] } for this user, @@ -49,10 +125,16 @@ def get_graph(self, user_id: str) -> dict: validate_user_id(user_id) docs = get_user_documents(user_id) - nodes = [ - {"id": doc_id, "title": data["title"], "node_type": "document"} - for doc_id, data in docs.items() - ] + nodes = [] + for doc_id, data in docs.items(): + metadata = node_store.get_node_metadata(user_id, doc_id) + nodes.append({ + "id": doc_id, + "title": data["title"], + "node_type": "document", + "definition": metadata["definition"], # [Task 4] "" if not yet generated + "source_document": data["title"], # [Task 4] same as title for document-level nodes + }) edges = graph_store.get_edges(user_id) @@ -61,4 +143,5 @@ def get_graph(self, user_id: str) -> dict: def delete_graph(self, user_id: str) -> dict: validate_user_id(user_id) graph_store.delete_edges(user_id) - return {"user_id": user_id, "deleted": True} + node_store.delete_node_metadata(user_id) + return {"user_id": user_id, "deleted": True} \ No newline at end of file diff --git a/ai-ml/knowledge_graph/app/storage/graph_store.py b/ai-ml/knowledge_graph/app/storage/graph_store.py index 5c40d2f..9e18933 100644 --- a/ai-ml/knowledge_graph/app/storage/graph_store.py +++ b/ai-ml/knowledge_graph/app/storage/graph_store.py @@ -8,8 +8,7 @@ already uses. Not safe for many concurrent writers, but fine for how this module -is used today: rebuilt on-demand per user, not written to under -sustained concurrent load. +is used today. """ import json import os @@ -43,15 +42,14 @@ def _write_all(edges: list) -> None: def save_edges(user_id: str, edges: list, node_type: str) -> int: """ - Replaces all existing edges of the given node_type for this user - with the new set (a full rebuild, not an incremental append — - keeps the graph consistent with the latest embedded content). - edges: list of GraphEdge.to_dict() results. - Returns the number of edges written. + Full-replace: removes ALL existing edges of the given node_type + for this user, then writes the new set. Used by build_graph()'s + full rebuild — NOT safe to use for incremental updates, since it + would delete edges you meant to keep. Use append_edges() instead + when adding to an existing graph rather than rebuilding it. """ with _lock: all_edges = _read_all() - # drop this user's existing edges of this type, keep everyone else's all_edges = [ e for e in all_edges if not (e["user_id"] == user_id and e["node_type"] == node_type) @@ -61,6 +59,21 @@ def save_edges(user_id: str, edges: list, node_type: str) -> int: return len(edges) +def append_edges(user_id: str, new_edges: list, node_type: str) -> int: + """ + [Task 5] Adds edges without deleting any existing ones — for + incremental updates when a single new document is added, rather + than a full graph rebuild. Use save_edges() instead when you + genuinely want to replace the full edge set (e.g. manual + /graph/rebuild). + """ + with _lock: + all_edges = _read_all() + all_edges.extend(new_edges) + _write_all(all_edges) + return len(new_edges) + + def get_edges(user_id: str, node_type: str = None) -> list: all_edges = _read_all() result = [e for e in all_edges if e["user_id"] == user_id] @@ -73,4 +86,4 @@ def delete_edges(user_id: str) -> None: with _lock: all_edges = _read_all() all_edges = [e for e in all_edges if e["user_id"] != user_id] - _write_all(all_edges) + _write_all(all_edges) \ No newline at end of file diff --git a/ai-ml/knowledge_graph/app/storage/node_store.py b/ai-ml/knowledge_graph/app/storage/node_store.py new file mode 100644 index 0000000..34dfd83 --- /dev/null +++ b/ai-ml/knowledge_graph/app/storage/node_store.py @@ -0,0 +1,68 @@ +""" +[Task 4] Stores per-node metadata (currently: definitions) alongside +the existing edge storage in graph_store.py. Same JSON-file-in-shared- +directory pattern, kept as a separate file/store since nodes and +edges have different lifecycles: a node's definition is generated +once per document, while edges can be added/removed independently. +""" +import json +import os +from pathlib import Path +from threading import Lock + +from knowledge_graph.app.config import CHROMA_DB_PATH + +_NODES_FILENAME = "knowledge_graph_node_metadata.json" +_lock = Lock() + + +def _path() -> Path: + return Path(CHROMA_DB_PATH) / _NODES_FILENAME + + +def _read_all() -> dict: + p = _path() + if not p.exists(): + return {} + with open(p, "r", encoding="utf-8") as f: + return json.load(f) + + +def _write_all(data: dict) -> None: + p = _path() + os.makedirs(p.parent, exist_ok=True) + with open(p, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + + +def save_node_metadata(user_id: str, document_id: str, definition: str) -> None: + """ + Stores a document's definition, keyed by user_id + document_id so + it can be looked up per-user without collisions across users. + """ + key = f"{user_id}::{document_id}" + with _lock: + data = _read_all() + data[key] = {"definition": definition} + _write_all(data) + + +def get_node_metadata(user_id: str, document_id: str) -> dict: + """ + Returns {"definition": str}. Returns {"definition": ""} if no + metadata has been generated for this document yet (e.g. it was + ingested before this feature existed) — callers should treat an + empty definition as "not available" rather than an error. + """ + key = f"{user_id}::{document_id}" + data = _read_all() + return data.get(key, {"definition": ""}) + + +def delete_node_metadata(user_id: str) -> None: + """Removes all node metadata for a user (mirrors delete_edges()).""" + prefix = f"{user_id}::" + with _lock: + data = _read_all() + data = {k: v for k, v in data.items() if not k.startswith(prefix)} + _write_all(data) \ No newline at end of file diff --git a/ai-ml/knowledge_graph/app/utils/definition_generator.py b/ai-ml/knowledge_graph/app/utils/definition_generator.py new file mode 100644 index 0000000..196feff --- /dev/null +++ b/ai-ml/knowledge_graph/app/utils/definition_generator.py @@ -0,0 +1,68 @@ +""" +Generates a short, one-sentence definition/summary for a document, +used to populate the knowledge graph's node detail view. Uses Groq — +same provider already used by relationship_classifier.py — so no new +API dependency. + +Falls back to an empty string on any failure (missing key, network +error, malformed response) rather than raising, so a definition +problem never breaks graph building or ingestion. +""" +import os +from pathlib import Path + +from dotenv import load_dotenv +from groq import Groq + +# Load .env directly rather than relying on some other module having +# already loaded it first — makes this file safe to import and use +# standalone, not just as part of the full ingestion pipeline. +load_dotenv(dotenv_path=Path(__file__).resolve().parent.parent.parent.parent / ".env") + +DEFAULT_DEFINITION = "" + +_PROMPT_TEMPLATE = """Write ONE short sentence (max 25 words) defining what this study document is about, suitable for a student browsing a knowledge graph. Do not include the document's title in your answer. Respond with ONLY the sentence, no preamble. + +Document text: +{text} + +Definition:""" + +_client = None + + +def _get_client(): + global _client + if _client is None: + api_key = os.getenv("GROQ_API_KEY", "") + if not api_key: + raise RuntimeError("GROQ_API_KEY is not set") + _client = Groq(api_key=api_key) + return _client + + +def generate_definition(text: str, model: str = "openai/gpt-oss-20b") -> str: + """ + Returns a short definition string, or DEFAULT_DEFINITION ("") on + any failure. Never raises. + """ + if not text or not text.strip(): + return DEFAULT_DEFINITION + + try: + client = _get_client() + response = client.chat.completions.create( + model=model, + messages=[{ + "role": "user", + "content": _PROMPT_TEMPLATE.format(text=text[:1500]), + }], + temperature=0.3, + max_tokens=300, + reasoning_effort="low", + ) + definition = response.choices[0].message.content.strip() + return definition or DEFAULT_DEFINITION + + except Exception: + return DEFAULT_DEFINITION \ No newline at end of file diff --git a/ai-ml/knowledge_graph/app/utils/relationship_classifier.py b/ai-ml/knowledge_graph/app/utils/relationship_classifier.py new file mode 100644 index 0000000..561cfe4 --- /dev/null +++ b/ai-ml/knowledge_graph/app/utils/relationship_classifier.py @@ -0,0 +1,99 @@ +""" +Classifies the semantic relationship between two pieces of text using +an LLM (Groq — same provider already used by definition_generator.py +and the chatbot elsewhere in this project, so no new API dependency). + +Falls back to "related_to" (the existing default) on any failure — +missing API key, network error, malformed response, or an +unrecognized label — so a classification problem degrades gracefully +instead of breaking graph building entirely. +""" +import os +from pathlib import Path + +from dotenv import load_dotenv +from groq import Groq + +# Load .env directly rather than relying on some other module having +# already loaded it first — makes this file safe to import and use +# standalone, not just as part of the full ingestion pipeline. +load_dotenv(dotenv_path=Path(__file__).resolve().parent.parent.parent.parent / ".env") + +# The fixed set of relationship types the graph understands. Keeping +# this closed (rather than letting the LLM invent arbitrary labels) +# makes the field predictable for the frontend to filter/display on. +VALID_RELATIONSHIP_TYPES = { + "prerequisite_of", + "example_of", + "contrasts_with", + "related_to", # fallback / general case +} + +DEFAULT_RELATIONSHIP_TYPE = "related_to" + +_PROMPT_TEMPLATE = """You are classifying the relationship between two pieces of study content. Respond with EXACTLY ONE of these labels, nothing else: + +prerequisite_of - text A should be understood before text B +example_of - text B is a specific example illustrating a concept from text A +contrasts_with - text A and text B present differing or opposing ideas +related_to - they share a topic but none of the above apply clearly + +Text A: +{text_a} + +Text B: +{text_b} + +Label:""" + +_client = None + + +def _get_client(): + global _client + if _client is None: + api_key = os.getenv("GROQ_API_KEY", "") + if not api_key: + raise RuntimeError("GROQ_API_KEY is not set") + _client = Groq(api_key=api_key) + return _client + + +def classify_relationship(text_a: str, text_b: str, model: str = "openai/gpt-oss-20b") -> str: + """ + Returns one of VALID_RELATIONSHIP_TYPES. Never raises — any + failure (missing key, network error, unexpected model output) + falls back to DEFAULT_RELATIONSHIP_TYPE, so a classification + issue never breaks graph building. + + [Fix] gpt-oss-20b is a reasoning model — Groq bills its hidden + internal reasoning against max_tokens. At default reasoning + effort, short-answer prompts can exhaust the whole token budget + on reasoning and return empty content (finish_reason: "length"). + reasoning_effort="low" keeps enough budget free for the actual + one-word answer. (This surfaced first in definition_generator.py, + which produces longer output and hit the failure reliably; this + classifier likely had the same latent risk with shorter answers.) + """ + try: + client = _get_client() + response = client.chat.completions.create( + model=model, + messages=[{ + "role": "user", + "content": _PROMPT_TEMPLATE.format(text_a=text_a[:500], text_b=text_b[:500]), + }], + temperature=0, + max_tokens=100, + reasoning_effort="low", + ) + raw_label = response.choices[0].message.content.strip().lower() + + for label in VALID_RELATIONSHIP_TYPES: + if label in raw_label: + return label + + return DEFAULT_RELATIONSHIP_TYPE + + except Exception: + return DEFAULT_RELATIONSHIP_TYPE \ No newline at end of file diff --git a/ai-ml/knowledge_graph/tests/test_graph_service.py b/ai-ml/knowledge_graph/tests/test_graph_service.py new file mode 100644 index 0000000..d645cf8 --- /dev/null +++ b/ai-ml/knowledge_graph/tests/test_graph_service.py @@ -0,0 +1,144 @@ +""" +Integration tests for GraphService, against an isolated, in-memory +ChromaDB collection — NOT the real shared store. + +This isolation matters: an earlier version of this file used the +real persistent collection (embedding.chroma_store.get_collection()) +directly, which wrote 16-dimension synthetic test vectors into the +actual shared store. Since a Chroma collection's dimension is fixed +by its first insert, this locked the real collection to 16 +dimensions — incompatible with the real embedding model's 384 +dimensions, breaking real ingestion. This version fixes that by +patching get_collection() to use a fresh, in-memory client for every +test, so nothing here ever touches real data. + +Run: pytest knowledge_graph/tests/test_graph_service.py +""" +import numpy as np +import pytest +import chromadb + +from knowledge_graph.app.builders import document_graph_builder +from knowledge_graph.app.services.graph_service import GraphService + + +@pytest.fixture(autouse=True) +def isolated_chroma_collection(monkeypatch): + """ + Redirects document_graph_builder's get_collection() to a fresh, + in-memory ChromaDB client for the duration of each test. Applied + automatically to every test in this file — no test here should + ever be able to touch the real persistent store. + """ + test_client = chromadb.EphemeralClient() + + def fake_get_collection(name="study_chunks", path=None): + return test_client.get_or_create_collection(name=name) + + monkeypatch.setattr(document_graph_builder, "get_collection", fake_get_collection) + + +def _make_vec(base: float, dim: int = 384, seed: int = 0) -> list: + """ + dim defaults to 384 (matching the real embedding model's output + size) specifically so a dimension mismatch like the one that + corrupted the real store can't recur even if isolation is ever + accidentally removed. + """ + rng = np.random.default_rng(seed) + return rng.normal(loc=base, scale=0.03, size=dim).tolist() + + +@pytest.fixture +def seeded_user(): + """Inserts two related documents and one unrelated document for a test user.""" + user_id = "pytest_user" + collection = document_graph_builder.get_collection() + + rows = [ + ("docA", 0, "ML Basics", "Neural networks learn from data.", 0.8, 1), + ("docA", 1, "ML Basics", "Training uses gradient descent.", 0.8, 2), + ("docB", 0, "Deep Learning", "Gradient descent trains neural networks.", 0.82, 3), + ("docC", 0, "Cooking", "Bread requires yeast and flour.", -0.8, 4), + ] + ids, embeddings, documents, metadatas = [], [], [], [] + for doc_id, idx, title, text, base, seed in rows: + ids.append(f"{doc_id}_{idx}") + embeddings.append(_make_vec(base, seed=seed)) + documents.append(text) + metadatas.append({"user_id": user_id, "document_id": doc_id, "document": title, "chunk_index": idx}) + + collection.upsert(ids=ids, embeddings=embeddings, documents=documents, metadatas=metadatas) + return user_id + + +def test_build_graph_links_related_documents(seeded_user): + service = GraphService() + result = service.build_graph(seeded_user) + assert result["document_edges_created"] >= 1 + + graph = service.get_graph(seeded_user) + doc_edges = [e for e in graph["edges"] if e["node_type"] == "document"] + titles = {(e["source_title"], e["target_title"]) for e in doc_edges} + + assert ("ML Basics", "Deep Learning") in titles or ("Deep Learning", "ML Basics") in titles + + +def test_build_graph_does_not_link_unrelated_documents(seeded_user): + service = GraphService() + service.build_graph(seeded_user) + graph = service.get_graph(seeded_user) + + doc_edges = [e for e in graph["edges"] if e["node_type"] == "document"] + cooking_involved = any( + "Cooking" in (e["source_title"], e["target_title"]) for e in doc_edges + ) + assert not cooking_involved + + +def test_get_graph_nodes_match_documents(seeded_user): + service = GraphService() + graph = service.get_graph(seeded_user) + titles = {n["title"] for n in graph["nodes"]} + assert titles == {"ML Basics", "Deep Learning", "Cooking"} + + +def test_delete_graph_clears_edges(seeded_user): + service = GraphService() + service.build_graph(seeded_user) + service.delete_graph(seeded_user) + + graph = service.get_graph(seeded_user) + assert graph["edges"] == [] + + +def test_user_with_no_documents_does_not_crash(): + service = GraphService() + result = service.build_graph("nobody_has_this_id") + assert result["document_edges_created"] == 0 + assert result["topic_edges_created"] == 0 + + +def test_empty_user_id_raises(): + service = GraphService() + with pytest.raises(ValueError): + service.get_graph("") + + +def test_no_duplicate_document_edges(seeded_user): + service = GraphService() + service.build_graph(seeded_user) + graph = service.get_graph(seeded_user) + + doc_edges = [e for e in graph["edges"] if e["node_type"] == "document"] + pairs_seen = [frozenset((e["source_id"], e["target_id"])) for e in doc_edges] + assert len(pairs_seen) == len(set(pairs_seen)), "duplicate edge detected" + + +def test_no_self_referencing_edges(seeded_user): + service = GraphService() + service.build_graph(seeded_user) + graph = service.get_graph(seeded_user) + + for e in graph["edges"]: + assert e["source_id"] != e["target_id"], f"self-link found: {e}" \ No newline at end of file diff --git a/ai-ml/knowledge_graph/tests/test_relationship_classifier.py b/ai-ml/knowledge_graph/tests/test_relationship_classifier.py new file mode 100644 index 0000000..fe16274 --- /dev/null +++ b/ai-ml/knowledge_graph/tests/test_relationship_classifier.py @@ -0,0 +1,80 @@ +""" +Tests for relationship_classifier.py. All Groq API calls are mocked — +these test the module's own logic (fallback safety, label parsing), +not the LLM itself. + +Run: pytest knowledge_graph/tests/test_relationship_classifier.py +""" +from unittest.mock import patch, MagicMock + +import pytest + +import knowledge_graph.app.utils.relationship_classifier as rc + + +@pytest.fixture(autouse=True) +def reset_client(): + """Ensures the singleton Groq client doesn't leak between tests.""" + rc._client = None + yield + rc._client = None + + +def test_missing_api_key_falls_back_safely(monkeypatch): + monkeypatch.delenv("GROQ_API_KEY", raising=False) + result = rc.classify_relationship("text A", "text B") + assert result == rc.DEFAULT_RELATIONSHIP_TYPE + + +def test_valid_classification_returned(monkeypatch): + monkeypatch.setenv("GROQ_API_KEY", "fake-key") + with patch("knowledge_graph.app.utils.relationship_classifier.Groq") as MockGroq: + mock_response = MagicMock() + mock_response.choices[0].message.content = "prerequisite_of" + MockGroq.return_value.chat.completions.create.return_value = mock_response + + result = rc.classify_relationship("Intro to ML", "Decision Trees") + assert result == "prerequisite_of" + + +def test_messy_output_still_parses_correctly(monkeypatch): + monkeypatch.setenv("GROQ_API_KEY", "fake-key") + with patch("knowledge_graph.app.utils.relationship_classifier.Groq") as MockGroq: + mock_response = MagicMock() + mock_response.choices[0].message.content = " Prerequisite_Of!!! (probably)" + MockGroq.return_value.chat.completions.create.return_value = mock_response + + result = rc.classify_relationship("A", "B") + assert result == "prerequisite_of" + + +def test_unrecognized_output_falls_back(monkeypatch): + monkeypatch.setenv("GROQ_API_KEY", "fake-key") + with patch("knowledge_graph.app.utils.relationship_classifier.Groq") as MockGroq: + mock_response = MagicMock() + mock_response.choices[0].message.content = "banana" + MockGroq.return_value.chat.completions.create.return_value = mock_response + + result = rc.classify_relationship("A", "B") + assert result == rc.DEFAULT_RELATIONSHIP_TYPE + + +def test_api_failure_falls_back_without_crashing(monkeypatch): + monkeypatch.setenv("GROQ_API_KEY", "fake-key") + with patch("knowledge_graph.app.utils.relationship_classifier.Groq") as MockGroq: + MockGroq.return_value.chat.completions.create.side_effect = Exception("network error") + + result = rc.classify_relationship("A", "B") + assert result == rc.DEFAULT_RELATIONSHIP_TYPE + + +@pytest.mark.parametrize("label", ["prerequisite_of", "example_of", "contrasts_with", "related_to"]) +def test_all_valid_types_recognized(monkeypatch, label): + monkeypatch.setenv("GROQ_API_KEY", "fake-key") + with patch("knowledge_graph.app.utils.relationship_classifier.Groq") as MockGroq: + mock_response = MagicMock() + mock_response.choices[0].message.content = label + MockGroq.return_value.chat.completions.create.return_value = mock_response + + result = rc.classify_relationship("A", "B") + assert result == label diff --git a/ai-ml/requirements.txt b/ai-ml/requirements.txt index 37b8356..3f33d27 100644 --- a/ai-ml/requirements.txt +++ b/ai-ml/requirements.txt @@ -21,4 +21,6 @@ pytest PyJWT docling pytesseract -Pillow \ No newline at end of file +Pillow +openai-whisper +yt-dlp \ No newline at end of file diff --git a/docs/api-contracts.md b/docs/api-contracts.md index af16a24..7625899 100644 --- a/docs/api-contracts.md +++ b/docs/api-contracts.md @@ -335,6 +335,12 @@ Requires `Authorization: Bearer ` (same scheme as `/ask`). `user_id` comes | `400` | Invalid `quiz_type`, or no relevant content found for `topic` (returned as `success: false` in body) | | `502` | Upstream generation error (e.g. LLM returned malformed data) | + +## Knowledge Graph (Team Lambda) + +**Service root:** `ai-ml/knowledge_graph/` + +**Default local base URL:** `http://127.0.0.1:8005` ## Roadmap Generation (Team Lambda) **Service root:** `ai-ml/roadmap_generator/` @@ -345,6 +351,16 @@ Requires `Authorization: Bearer ` (same scheme as `/ask`). `user_id` comes ```bash cd ai-ml +uvicorn knowledge_graph.app.api.graph_routes:app --reload --port 8005 +``` + +Interactive docs: [http://127.0.0.1:8005/docs](http://127.0.0.1:8005/docs) + +**Pipeline:** groups a user's embedded chunks by document → averages each document's vectors → compares every pair (cosine similarity) → creates an edge above threshold → labels it via shared-keyword extraction, with a `relationship_type` field reserved for future LLM-based semantic classification (currently defaults to `"related_to"` for all edges pending that work). + +#### Authentication + +Requires header: `Authorization: Bearer `, same scheme as Quiz Generation and Chatbot — verified against `JWT_SECRET_KEY` (HS256), identity read from the token's `sub` claim. uvicorn roadmap_generator.app.main:app --reload --port 8004 ``` @@ -363,6 +379,81 @@ Interactive docs: [http://127.0.0.1:8004/docs](http://127.0.0.1:8004/docs) --- +### `GET /graph` + +Returns the authenticated user's current knowledge graph — all embedded documents as nodes, all above-threshold relationships as edges. Does not rebuild the graph; call `/graph/rebuild` first if new content has been added since the last build. + +#### Response `200` + +```json +{ + "nodes": [ + {"id": "6561c06b-...", "title": "decision_trees", "node_type": "document"}, + {"id": "82c4705e-...", "title": "machine_learning_intro", "node_type": "document"} + ], + "edges": [ + { + "user_id": "test_user", + "source_id": "6561c06b-...", + "target_id": "82c4705e-...", + "node_type": "document", + "similarity": 0.6528, + "source_title": "decision_trees", + "target_title": "machine_learning_intro", + "label": "shared terms: algorithms, learning, machine", + "relationship_type": "related_to" + } + ] +} +``` + +| Field | Type | Notes | +|-------|------|--------| +| `nodes[].id` | string | Document ID | +| `nodes[].title` | string | Document title | +| `nodes[].node_type` | string | Currently always `"document"` | +| `edges[].node_type` | string | `"document"` or `"topic"` — which graph layer this edge belongs to | +| `edges[].similarity` | float | Cosine similarity score, 0–1 | +| `edges[].label` | string | Shared-keyword explanation of the connection | +| `edges[].relationship_type` | string | Currently always `"related_to"` — reserved for future LLM-based classification (e.g. `"prerequisite_of"`, `"example_of"`) | + +--- + +### `POST /graph/rebuild` + +Rebuilds the authenticated user's graph from their current embedded content — deletes existing edges and recreates them fresh. + +#### Response `200` + +```json +{ + "user_id": "test_user", + "document_edges_created": 1, + "topic_edges_created": 1 +} +``` + +--- + +### `DELETE /graph` + +Deletes all graph edges for the authenticated user. + +#### Response `200` + +```json +{"user_id": "test_user", "deleted": true} +``` + +--- + +### Errors + +| Status | When | +|--------|------| +| `403` | Missing `Authorization` header | +| `401` | Token present but invalid/expired | +| `500` | Server missing `JWT_SECRET_KEY` | ### `POST /generate-roadmap` Supports three generation modes via the `mode` field. Exactly one mode's required field(s) must be supplied. @@ -446,6 +537,7 @@ No mode-specific fields — uses the authenticated user's Weak Topic Detection r #### Errors + | Status | When | |--------|------| | `400` | Missing mode-specific required field; invalid `mode`; no content/topics found for the given input |