Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions MosipNexus/.env.example
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
# ── Optional: Groq API key (server-side ingestion only) ────────────────────────
# Used by the ingestion pipeline (thread summarizer). NOT used by the web app or
# API — users provide their own LLM key via the Settings page (BYOK) or Claude
# Desktop MCP integration.
GROQ_API_KEY=gsk_your_key_here # https://console.groq.com (free tier)
# ── Required ───────────────────────────────────────────────────────────────────
GROQ_API_KEY=YOUR_GROQ_API_KEY # https://console.groq.com (free tier)

# ── Optional: HuggingFace token (avoids rate-limiting on model downloads) ──────
HF_TOKEN=hf_your_token_here
Expand Down Expand Up @@ -39,3 +36,6 @@ SMTP_PORT=587
SMTP_USER=your_gmail@gmail.com
SMTP_PASSWORD=your_app_password_here
NOTIFY_EMAIL=mosip-team@example.com # recipient address

YOUTUBE_API_KEY=YOUR_YOUTUBE_API_KEY
YOUTUBE_CHANNEL_HANDLE=mosip16
19 changes: 16 additions & 3 deletions MosipNexus/app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from chain.query_engine import ask
from memory.session import SessionMemory
from retrieval.dedup import find_similar_question
from config.settings import GROQ_API_KEY

DetectorFactory.seed = 0

Expand Down Expand Up @@ -84,7 +85,7 @@ def _export_chat_html(messages: list, language: str) -> str:
links = ""
seen: set = set()
icons = {"docs": "📄", "community": "💬", "github": "🐙",
"code": "🧑‍💻", "confluence": "📘"}
"code": "🧑‍💻", "confluence": "📘","youtube": "▶️"}
for src in msg["sources"]:
url = src.get("source", "")
title = src.get("title") or url
Expand Down Expand Up @@ -199,7 +200,8 @@ def _detect_lang_instruction(text: str) -> tuple[str, str] | None:
"code": "Source Code",
"confluence": "Confluence",
"jira": "Jira Tickets",
"mixed": "Docs · Community · GitHub · Code",
"youtube": "YouTube",
"mixed": "Docs · Community · GitHub · Code · YouTube",
"web": "Web Sources",
"none": "",
"chat": "",
Expand Down Expand Up @@ -249,6 +251,9 @@ def _svg(path_d: str, *, filled: bool = False) -> str:
'<path d="M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2'
'a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z"/>'
),
"youtube": _svg(
'<polygon points="10,8 16,12 10,16"/>',
),
"web": _svg(
'<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/>'
'<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10'
Expand All @@ -266,6 +271,7 @@ def _svg(path_d: str, *, filled: bool = False) -> str:
("github", "GitHub Issues"),
("confluence", "Confluence"),
("code", "Source Code"),
("youtube", "YouTube"),
]
)
+ "</div>"
Expand Down Expand Up @@ -509,10 +515,17 @@ def _detect_language(text: str, current_lang_code: str = "en") -> tuple[str, str

# ── Persistent session state defaults (must match settings.py) ────────────────
if "llm_api_key" not in st.session_state:
st.session_state["llm_api_key"] = ""

st.session_state["llm_api_key"] = GROQ_API_KEY or ""

if "llm_provider" not in st.session_state:

st.session_state["llm_provider"] = "groq"

if "llm_model" not in st.session_state:

st.session_state["llm_model"] = "llama-3.3-70b-versatile"

# ── No-LLM banner ─────────────────────────────────────────────────────────────
if not st.session_state.get("llm_api_key"):
st.markdown(
Expand Down
19 changes: 7 additions & 12 deletions MosipNexus/app/pages/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import sys
from pathlib import Path
from config.settings import GROQ_API_KEY

sys.path.insert(0, str(Path(__file__).parent.parent.parent))

Expand Down Expand Up @@ -168,7 +169,7 @@

# ── Persistent session state defaults (survive page navigation) ───────────────
if "llm_api_key" not in st.session_state:
st.session_state["llm_api_key"] = ""
st.session_state["llm_api_key"] = GROQ_API_KEY or ""
if "llm_provider" not in st.session_state:
st.session_state["llm_provider"] = "groq"

Expand Down Expand Up @@ -247,22 +248,16 @@
unsafe_allow_html=True,
)
# Seed the widget key from the persistent key when returning to this page
if "_api_key_widget" not in st.session_state:
st.session_state["_api_key_widget"] = st.session_state["llm_api_key"]

def _sync_api_key():
# on_change fires before script reruns — persistent key is always current
st.session_state["llm_api_key"] = st.session_state.get("_api_key_widget", "")

st.text_input(
"API Key",
value="Loaded from .env"
type="password",
placeholder=key_placeholder,
key="_api_key_widget",
on_change=_sync_api_key,
disabled=True,
label_visibility="collapsed",
help="API key is loaded from the .env file."
)
api_key = st.session_state.get("llm_api_key", "")

api_key = GROQ_API_KEY
Comment on lines +253 to +260

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Fix the syntax error.

The missing comma after the value argument causes a SyntaxError that crashes the application. Such service outages violate MOSIP operational availability and compliance baselines.

🐛 Proposed fix
-            value="Loaded from .env"
+            value="Loaded from .env",
             type="password",
             disabled=True,  
             label_visibility="collapsed",
             help="API key is loaded from the .env file."
         )
 
         api_key = GROQ_API_KEY
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
value="Loaded from .env"
type="password",
placeholder=key_placeholder,
key="_api_key_widget",
on_change=_sync_api_key,
disabled=True,
label_visibility="collapsed",
help="API key is loaded from the .env file."
)
api_key = st.session_state.get("llm_api_key", "")
api_key = GROQ_API_KEY
value="Loaded from .env",
type="password",
disabled=True,
label_visibility="collapsed",
help="API key is loaded from the .env file."
)
api_key = GROQ_API_KEY
🧰 Tools
🪛 Ruff (0.15.21)

[warning] 254-254: Expected ,, found type

(invalid-syntax)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@MosipNexus/app/pages/settings.py` around lines 253 - 260, Fix the syntax
error in the API key widget arguments by adding the missing comma after the
value argument in the settings page, before the type argument. Keep the existing
disabled password-field configuration and GROQ_API_KEY assignment unchanged.

Source: Linters/SAST tools


with col_model:
st.caption("Model")
Expand Down
13 changes: 8 additions & 5 deletions MosipNexus/chain/query_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,8 @@ def ask(
source_type = "confluence"
elif source_types == {"jira"}:
source_type = "jira"
elif source_types == {"youtube"}:
source_type = "youtube"
else:
source_type = "mixed"

Expand All @@ -552,11 +554,12 @@ def ask(
]

sources = (
_doc_filtered +
_by_type.get("community", [])[:2] +
_by_type.get("github", [])[:1] +
_by_type.get("code", [])[:1] +
_by_type.get("confluence", [])[:1]
_doc_filtered
+ _by_type.get("community", [])[:2]
+ _by_type.get("github", [])[:1]
+ _by_type.get("code", [])[:1]
+ _by_type.get("confluence", [])[:1]
+ _by_type.get("youtube", [])[:2]
)

# ── Similar questions (community titles in the results) ────────────────────
Expand Down
9 changes: 8 additions & 1 deletion MosipNexus/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
# ── Retrieval ──────────────────────────────────────────────────────────────────
RETRIEVAL_K = 8 # final chunks returned per collection
RETRIEVAL_FETCH_K = 30 # MMR candidate pool
MAX_CONTEXT_DOCS = 40 # hard cap on total docs passed to LLM (prevents context overflow)
MAX_CONTEXT_DOCS = 10

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

MAX_CONTEXT_DOCS cut 75% (40→10) while adding a 5th competing source.

Per retriever.py, the final context is _deduped[:MAX_CONTEXT_DOCS] over the concatenation of prod-code, docs, community, github, youtube, and demo-code results — there's no per-source floor. Dropping the cap to 10 at the same time YouTube is added as a new competitor for that slice risks starving existing sources (docs/community/github/code) of context, potentially degrading answer quality/citations for previously working queries.

Consider either a higher cap or per-source minimums (e.g., reserve N slots per collection before the global truncation) so YouTube doesn't crowd out existing sources.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@MosipNexus/config/settings.py` at line 59, Increase MAX_CONTEXT_DOCS from 10
or add per-source minimum reservations before the global deduplication slice,
ensuring YouTube results cannot crowd out existing prod-code, docs, community,
GitHub, and demo-code context; preserve the final context limit and existing
source-priority behavior where applicable.


# Cosine similarity (1 = identical). Above this → treat as duplicate question.
DEDUP_THRESHOLD = 0.88
Expand Down Expand Up @@ -179,5 +179,12 @@
JIRA_FILE = DATA_DIR / "mosip_jira.json"
JIRA_COLLECTION = "mosip_jira"

# ── YouTube crawler ───────────────────────────────────────────────────────────
YOUTUBE_API_KEY = os.getenv("YOUTUBE_API_KEY", "")
YOUTUBE_CHANNEL_HANDLE = os.getenv("YOUTUBE_CHANNEL_HANDLE", "mosip16")

YOUTUBE_FILE = DATA_DIR / "mosip_youtube.json"
YOUTUBE_COLLECTION = "mosip_youtube"

# ── HTTP headers ───────────────────────────────────────────────────────────────
HTTP_HEADERS = {"User-Agent": "Mozilla/5.0 (compatible; MosipNexusBot/1.0)"}
216 changes: 216 additions & 0 deletions MosipNexus/crawler/youtube_crawler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
"""
YouTube crawler for MOSIP videos.
"""
import sys
import json
from pathlib import Path
from googleapiclient.discovery import build
sys.path.insert(0, str(Path(__file__).parent.parent))

from config.settings import (
YOUTUBE_API_KEY,
YOUTUBE_CHANNEL_HANDLE,
YOUTUBE_FILE,
)


def get_youtube_client():
"""Create a YouTube Data API client."""
return build(
"youtube",
"v3",
developerKey=YOUTUBE_API_KEY,
)


def get_channel_id(youtube):
"""Resolve the channel ID from the handle."""
response = youtube.channels().list(
part="id",
forHandle=YOUTUBE_CHANNEL_HANDLE,
).execute()

items = response.get("items", [])

if not items:
raise RuntimeError(
f"Channel not found: @{YOUTUBE_CHANNEL_HANDLE}"
)

return items[0]["id"]
def get_uploads_playlist_id(youtube, channel_id):
"""Return the uploads playlist ID for a channel."""
response = youtube.channels().list(
part="contentDetails",
id=channel_id,
).execute()

items = response.get("items", [])

if not items:
raise RuntimeError("Channel not found.")

return (
items[0]["contentDetails"]
["relatedPlaylists"]
["uploads"]
)
def get_all_videos(youtube, playlist_id):
"""Fetch all public videos from the uploads playlist."""

videos = []
next_page_token = None

while True:
response = youtube.playlistItems().list(
part="snippet",
playlistId=playlist_id,
maxResults=50,
pageToken=next_page_token,
).execute()

for item in response.get("items", []):
snippet = item["snippet"]

videos.append(
{
"video_id": snippet["resourceId"]["videoId"],
"title": snippet["title"],
"published_at": snippet["publishedAt"],
}
)

next_page_token = response.get("nextPageToken")

if not next_page_token:
break

return videos
from youtube_transcript_api import YouTubeTranscriptApi
from youtube_transcript_api._errors import (
NoTranscriptFound,
TranscriptsDisabled,
IpBlocked,
)

def fetch_transcript(video_id: str):
"""
Fetch transcript for a video.

Preference:
1. Manual English captions
2. Auto-generated English
3. Any available language
"""
api = YouTubeTranscriptApi()

try:
return api.fetch(
video_id,
languages=["en"],
)

except NoTranscriptFound:
pass

except TranscriptsDisabled:
print(f"WARN: transcripts disabled for {video_id}")
return None

except IpBlocked:
print(f"WARN: YouTube blocked transcript requests for {video_id}")
return None

except Exception as e:
print(f"WARN: transcript fetch failed for {video_id}: {e}")
return None

try:
return api.fetch(video_id)

except Exception as e:
print(f"WARN: transcript fetch failed for {video_id}: {e}")
return None
def transcript_to_segments(transcript):
"""Convert transcript snippets into plain dictionaries."""
segments = []

for snippet in transcript:
segments.append(
{
"text": snippet.text,
"start": snippet.start,
"duration": snippet.duration,
}
)

return segments
def build_video_document(video: dict):
"""
Build one structured document for a YouTube video.
"""

transcript = fetch_transcript(video["video_id"])

if transcript is None:
print(f"WARN: no transcript for {video['title']} ({video['video_id']})")
return None

return {
"video_id": video["video_id"],
"title": video["title"],
"published_at": video["published_at"],
"channel_name": "MOSIP",
"transcript": transcript_to_segments(transcript),
}
def crawl_youtube():
"""Fetch all YouTube videos and save them."""

youtube = get_youtube_client()

channel_id = get_channel_id(youtube)

playlist_id = get_uploads_playlist_id(
youtube,
channel_id,
)

videos = get_all_videos(
youtube,
playlist_id,
)

docs = []

print(f"Found {len(videos)} videos")

for i, video in enumerate(videos, start=1):

print(f"[{i}/{len(videos)}] {video['title']}")

doc = build_video_document(video)

if doc:
docs.append(doc)

with open(
YOUTUBE_FILE,
"w",
encoding="utf-8",
) as f:

json.dump(
docs,
f,
indent=2,
ensure_ascii=False,
)

print(f"\nSaved {len(docs)} videos")

return docs



if __name__ == "__main__":
crawl_youtube()
Loading
Loading