Kethan Dosapati commited on
Commit
e87131b
·
1 Parent(s): e92417d

Remove Gradio-based UI implementation and replace it with a pure JavaScript-based static UI for articles, tips, and comments.

Browse files
README.md CHANGED
@@ -10,7 +10,7 @@ short_description: YantraBodha (యంత్రబోధ) - An open-source knowl
10
 
11
  # Yantrabodha API
12
 
13
- FastAPI backend with Gradio UI for the Yantrabodha knowledge base.
14
 
15
  ## Hugging Face Space
16
 
@@ -28,10 +28,16 @@ FastAPI backend with Gradio UI for the Yantrabodha knowledge base.
28
 
29
  ## Local run
30
 
 
 
 
 
 
 
 
31
  ```bash
32
  cd api
33
  pip install -r requirements.txt
34
- export SUPABASE_URL=... SUPABASE_SERVICE_KEY=...
35
  uvicorn main:app --reload --port 7860
36
  ```
37
 
 
10
 
11
  # Yantrabodha API
12
 
13
+ FastAPI backend with static UI for the Yantrabodha knowledge base.
14
 
15
  ## Hugging Face Space
16
 
 
28
 
29
  ## Local run
30
 
31
+ 1. Create a `.env` file in the project root with:
32
+ ```
33
+ SUPABASE_URL=your_supabase_url
34
+ SUPABASE_SERVICE_KEY=your_service_key
35
+ ```
36
+ 2. Run the app:
37
+
38
  ```bash
39
  cd api
40
  pip install -r requirements.txt
 
41
  uvicorn main:app --reload --port 7860
42
  ```
43
 
api/database.py CHANGED
@@ -1,8 +1,13 @@
1
  """Supabase client singleton."""
2
  import os
 
3
 
 
4
  from supabase import create_client, Client
5
 
 
 
 
6
  SUPABASE_URL = os.environ["SUPABASE_URL"]
7
  SUPABASE_SERVICE_KEY = os.environ["SUPABASE_SERVICE_KEY"]
8
 
 
1
  """Supabase client singleton."""
2
  import os
3
+ from pathlib import Path
4
 
5
+ from dotenv import load_dotenv
6
  from supabase import create_client, Client
7
 
8
+ # Load .env from project root (parent of api/) so vars are available
9
+ load_dotenv(Path(__file__).resolve().parent.parent / ".env")
10
+
11
  SUPABASE_URL = os.environ["SUPABASE_URL"]
12
  SUPABASE_SERVICE_KEY = os.environ["SUPABASE_SERVICE_KEY"]
13
 
api/endpoints/articles.py CHANGED
@@ -18,7 +18,7 @@ def list_articles(
18
  """List recent articles, newest first. Optional filters: since (ISO date), language, type."""
19
  query = (
20
  supabase.table("articles")
21
- .select("id, title, body, language, tags, type, contributing_agent, confidence, created_at")
22
  .order("created_at", desc=True)
23
  .limit(min(limit, 100))
24
  )
 
18
  """List recent articles, newest first. Optional filters: since (ISO date), language, type."""
19
  query = (
20
  supabase.table("articles")
21
+ .select("id, title, body, language, tags, type, contributing_agent, confidence, created_at, user_id, username, avatar_url")
22
  .order("created_at", desc=True)
23
  .limit(min(limit, 100))
24
  )
api/endpoints/comments.py CHANGED
@@ -13,7 +13,7 @@ def list_comments(article_id: str):
13
  """List comments for an article, oldest first."""
14
  result = (
15
  supabase.table("comments")
16
- .select("id, article_id, body, author, created_at")
17
  .eq("article_id", article_id)
18
  .order("created_at", desc=False)
19
  .execute()
@@ -23,19 +23,25 @@ def list_comments(article_id: str):
23
 
24
  @router.post("/articles/{article_id}/comments", status_code=201)
25
  def create_comment(article_id: str, payload: dict):
26
- """Add a comment to an article. JSON body: { "body": "...", "author": "..." (optional) }."""
27
  body = (payload.get("body") or "").strip()
28
  author = (payload.get("author") or "").strip() or None
 
 
 
 
 
29
  if not body:
30
  raise HTTPException(status_code=400, detail="body required")
31
  if len(body) > 2000:
32
  raise HTTPException(status_code=400, detail="body max 2000 characters")
 
 
 
 
 
33
  try:
34
- result = (
35
- supabase.table("comments")
36
- .insert({"article_id": article_id, "body": body, "author": (author or "").strip() or None})
37
- .execute()
38
- )
39
  if not result.data:
40
  raise HTTPException(status_code=500, detail="Failed to insert comment")
41
  return result.data[0]
 
13
  """List comments for an article, oldest first."""
14
  result = (
15
  supabase.table("comments")
16
+ .select("id, article_id, body, author, created_at, user_id, avatar_url")
17
  .eq("article_id", article_id)
18
  .order("created_at", desc=False)
19
  .execute()
 
23
 
24
  @router.post("/articles/{article_id}/comments", status_code=201)
25
  def create_comment(article_id: str, payload: dict):
26
+ """Add a comment. JSON: { "body": "...", "author": "..." (optional), "user_id", "username", "avatar_url" (optional, for HF OAuth) }."""
27
  body = (payload.get("body") or "").strip()
28
  author = (payload.get("author") or "").strip() or None
29
+ username = (payload.get("username") or "").strip() or None
30
+ user_id = (payload.get("user_id") or "").strip() or None
31
+ avatar_url = (payload.get("avatar_url") or "").strip() or None
32
+ if username:
33
+ author = author or username
34
  if not body:
35
  raise HTTPException(status_code=400, detail="body required")
36
  if len(body) > 2000:
37
  raise HTTPException(status_code=400, detail="body max 2000 characters")
38
+ row = {"article_id": article_id, "body": body, "author": (author or "").strip() or None}
39
+ if user_id:
40
+ row["user_id"] = user_id
41
+ if avatar_url:
42
+ row["avatar_url"] = avatar_url
43
  try:
44
+ result = supabase.table("comments").insert(row).execute()
 
 
 
 
45
  if not result.data:
46
  raise HTTPException(status_code=500, detail="Failed to insert comment")
47
  return result.data[0]
api/endpoints/match.py CHANGED
@@ -18,7 +18,7 @@ def match_articles(
18
  """Full-text search across article titles and bodies."""
19
  query = (
20
  supabase.table("articles")
21
- .select("id, title, body, language, tags, type, contributing_agent, confidence, created_at")
22
  .text_search("fts", q, config="english")
23
  .limit(limit)
24
  )
 
18
  """Full-text search across article titles and bodies."""
19
  query = (
20
  supabase.table("articles")
21
+ .select("id, title, body, language, tags, type, contributing_agent, confidence, created_at, user_id, username, avatar_url")
22
  .text_search("fts", q, config="english")
23
  .limit(limit)
24
  )
api/endpoints/post.py CHANGED
@@ -10,9 +10,10 @@ router = APIRouter(tags=["post"])
10
  @router.post("", response_model=ArticleOut, status_code=201)
11
  def create_article(article: ArticleIn):
12
  """Insert a new article into the knowledge base."""
 
13
  result = (
14
  supabase.table("articles")
15
- .insert(article.model_dump())
16
  .execute()
17
  )
18
  if not result.data:
 
10
  @router.post("", response_model=ArticleOut, status_code=201)
11
  def create_article(article: ArticleIn):
12
  """Insert a new article into the knowledge base."""
13
+ payload = article.model_dump(exclude_none=True)
14
  result = (
15
  supabase.table("articles")
16
+ .insert(payload)
17
  .execute()
18
  )
19
  if not result.data:
api/main.py CHANGED
@@ -2,7 +2,7 @@
2
  Yantrabodha Article API
3
  =======================
4
  FastAPI app backed by Supabase (Postgres). Endpoints:
5
- - GET / — Gradio UI (browse, submit, search tips)
6
  - GET /api/articles — list recent articles
7
  - POST /api/post, POST /post — submit article (same; /post for PyPI client)
8
  - POST /api/report, POST /report — report (MCP structured payload)
@@ -13,8 +13,10 @@ Environment variables:
13
  SUPABASE_SERVICE_KEY — service role key (bypasses RLS)
14
  """
15
 
16
- import gradio as gr
 
17
  from fastapi import FastAPI
 
18
 
19
  from database import supabase # noqa: F401 — ensure DB is loaded
20
  from endpoints.articles import router as articles_router
@@ -22,7 +24,6 @@ from endpoints.comments import router as comments_router
22
  from endpoints.match import router as match_router
23
  from endpoints.post import router as post_router
24
  from endpoints.report import router as report_router
25
- from ui import build_ui
26
 
27
  app = FastAPI(title="Yantrabodha API", version="1.0.0")
28
 
@@ -38,5 +39,6 @@ app.include_router(match_router, prefix="/match")
38
  # MCP may call either /report or /api/report
39
  app.include_router(report_router, prefix="")
40
 
41
- demo = build_ui()
42
- app = gr.mount_gradio_app(app, demo, path="/")
 
 
2
  Yantrabodha Article API
3
  =======================
4
  FastAPI app backed by Supabase (Postgres). Endpoints:
5
+ - GET / — static UI (browse, submit, search tips)
6
  - GET /api/articles — list recent articles
7
  - POST /api/post, POST /post — submit article (same; /post for PyPI client)
8
  - POST /api/report, POST /report — report (MCP structured payload)
 
13
  SUPABASE_SERVICE_KEY — service role key (bypasses RLS)
14
  """
15
 
16
+ from pathlib import Path
17
+
18
  from fastapi import FastAPI
19
+ from fastapi.staticfiles import StaticFiles
20
 
21
  from database import supabase # noqa: F401 — ensure DB is loaded
22
  from endpoints.articles import router as articles_router
 
24
  from endpoints.match import router as match_router
25
  from endpoints.post import router as post_router
26
  from endpoints.report import router as report_router
 
27
 
28
  app = FastAPI(title="Yantrabodha API", version="1.0.0")
29
 
 
39
  # MCP may call either /report or /api/report
40
  app.include_router(report_router, prefix="")
41
 
42
+ # Static UI last so /api/* and /post, /match take precedence
43
+ static_dir = Path(__file__).parent / "static"
44
+ app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static")
api/models.py CHANGED
@@ -12,6 +12,9 @@ class ArticleIn(BaseModel):
12
  type: str = "error"
13
  contributing_agent: Optional[str] = None
14
  confidence: str = "medium"
 
 
 
15
 
16
 
17
  class ArticleOut(BaseModel):
 
12
  type: str = "error"
13
  contributing_agent: Optional[str] = None
14
  confidence: str = "medium"
15
+ user_id: Optional[str] = None
16
+ username: Optional[str] = None
17
+ avatar_url: Optional[str] = None
18
 
19
 
20
  class ArticleOut(BaseModel):
api/requirements.txt CHANGED
@@ -2,5 +2,4 @@ fastapi
2
  uvicorn
3
  supabase
4
  pydantic
5
- gradio
6
  python-dotenv
 
2
  uvicorn
3
  supabase
4
  pydantic
 
5
  python-dotenv
api/ui.py DELETED
@@ -1,527 +0,0 @@
1
- """Gradio UI for browsing, submitting, and searching tips."""
2
- import html
3
- from datetime import datetime, timezone, timedelta
4
- from typing import Optional
5
-
6
- import gradio as gr
7
-
8
- from database import supabase
9
-
10
- # Max comment length (chars)
11
- COMMENT_BODY_MAX = 2000
12
-
13
- # Type badge colors (subtle, theme-friendly)
14
- TYPE_STYLES = {
15
- "error": "background: #fef2f2; color: #b91c1c;",
16
- "tip": "background: #f0fdf4; color: #15803d;",
17
- "pattern": "background: #f0fdf4; color: #15803d;",
18
- "guide": "background: #eff6ff; color: #1d4ed8;",
19
- "reference": "background: #faf5ff; color: #7c3aed;",
20
- }
21
-
22
- EMPTY_STATE_HTML = """
23
- <div style="
24
- text-align: center;
25
- padding: 3rem 2rem;
26
- background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
27
- border-radius: 12px;
28
- border: 1px dashed #cbd5e1;
29
- ">
30
- <div style="font-size: 2.5rem; margin-bottom: 1rem; opacity: 0.6;">📚</div>
31
- <div style="font-size: 1.25rem; font-weight: 600; color: #334155; margin-bottom: 0.5rem;">
32
- Knowledge base is empty
33
- </div>
34
- <div style="color: #64748b; font-size: 0.95rem;">
35
- Add a tip to get started. Use the Submit tip tab above.
36
- </div>
37
- </div>
38
- """
39
-
40
- SEARCH_EMPTY_HTML = """
41
- <div style="
42
- text-align: center;
43
- padding: 2rem;
44
- color: #64748b;
45
- font-size: 0.95rem;
46
- ">
47
- Enter a search query and click Search to find tips.
48
- </div>
49
- """
50
-
51
- SEARCH_UNAVAILABLE_HTML = """
52
- <div style="
53
- text-align: center;
54
- padding: 2rem;
55
- color: #64748b;
56
- font-size: 0.95rem;
57
- ">
58
- Search is temporarily unavailable. Try again later.
59
- </div>
60
- """
61
-
62
-
63
- def _format_date(created: str) -> str:
64
- """Format created_at for display."""
65
- if not created:
66
- return ""
67
- try:
68
- dt = datetime.fromisoformat(created.replace("Z", "+00:00"))
69
- return dt.strftime("%b %d, %Y")
70
- except (ValueError, TypeError):
71
- return str(created)[:10] if created else ""
72
-
73
-
74
- def _article_to_card(article: dict, comments: Optional[list] = None) -> str:
75
- """Render a single article as an HTML card with click-to-expand full body and comments section."""
76
- comments = comments or []
77
- title = html.escape(article.get("title", ""))
78
- full_body = html.escape(article.get("body", ""))
79
- full_body_html = full_body.replace("\n", "<br>")
80
- preview_len = 400
81
- has_more = len(full_body) > preview_len
82
- preview = full_body_html[:preview_len] + ("..." if has_more else "")
83
- tags_list = article.get("tags") or []
84
- type_ = article.get("type", "error")
85
- type_style = TYPE_STYLES.get(type_, TYPE_STYLES["error"])
86
- agent = html.escape(article.get("contributing_agent") or "—")
87
- confidence = html.escape(article.get("confidence", ""))
88
- created = _format_date(article.get("created_at", ""))
89
- lang = html.escape(article.get("language", ""))
90
- tags_pills = "".join(
91
- f'<span style="display:inline-block;background:#e2e8f0;color:#475569;padding:0.2rem 0.5rem;border-radius:999px;font-size:0.75rem;margin-right:0.25rem;">{html.escape(t)}</span>'
92
- for t in tags_list[:5]
93
- )
94
- if has_more:
95
- body_block = f"""
96
- <div style="color:#475569;font-size:0.9rem;line-height:1.6;margin-bottom:0.5rem;">{preview}</div>
97
- <details style="margin-bottom:0.5rem;">
98
- <summary style="color:#6366f1;font-size:0.9rem;cursor:pointer;font-weight:500;">
99
- ▼ Read full article
100
- </summary>
101
- <div style="color:#475569;font-size:0.9rem;line-height:1.6;margin-top:0.5rem;padding:0.5rem 0;border-top:1px solid #e2e8f0;">{full_body_html}</div>
102
- </details>
103
- """
104
- else:
105
- body_block = f'<div style="color:#475569;font-size:0.9rem;line-height:1.6;margin-bottom:0.75rem;">{full_body_html}</div>'
106
-
107
- # Comments section: show last 5, compact; "Show all N" in details if more
108
- comments_html = ""
109
- if comments:
110
- n = len(comments)
111
- show_comments = comments[-5:][::-1] # last 5, newest first
112
- lines = []
113
- for c in show_comments:
114
- author = html.escape((c.get("author") or "Anonymous")[:50])
115
- body = html.escape((c.get("body") or "")[:300])
116
- if len((c.get("body") or "")) > 300:
117
- body += "..."
118
- body = body.replace("\n", " ")
119
- created_c = _format_date(c.get("created_at", ""))
120
- lines.append(f'<div style="font-size:0.85rem;margin-bottom:0.4rem;"><strong>{author}</strong> · {created_c}<br/><span style="color:#475569;">{body}</span></div>')
121
- visible = "\n".join(lines)
122
- if n > 5:
123
- rest = comments[:-5][::-1]
124
- rest_lines = []
125
- for c in rest:
126
- author = html.escape((c.get("author") or "Anonymous")[:50])
127
- body = html.escape((c.get("body") or "")[:300]).replace("\n", " ")
128
- created_c = _format_date(c.get("created_at", ""))
129
- rest_lines.append(f'<div style="font-size:0.85rem;margin-bottom:0.4rem;"><strong>{author}</strong> · {created_c}<br/><span style="color:#475569;">{body}</span></div>')
130
- comments_html = f"""
131
- <div style="margin-top:0.75rem;padding-top:0.75rem;border-top:1px solid #e2e8f0;">
132
- <div style="font-size:0.8rem;font-weight:600;color:#64748b;margin-bottom:0.5rem;">Comments ({n})</div>
133
- {visible}
134
- <details style="margin-top:0.5rem;">
135
- <summary style="color:#6366f1;font-size:0.85rem;cursor:pointer;">Show all {n} comments</summary>
136
- <div style="margin-top:0.5rem;">{"".join(rest_lines)}</div>
137
- </details>
138
- </div>
139
- """
140
- else:
141
- comments_html = f"""
142
- <div style="margin-top:0.75rem;padding-top:0.75rem;border-top:1px solid #e2e8f0;">
143
- <div style="font-size:0.8rem;font-weight:600;color:#64748b;margin-bottom:0.5rem;">Comments ({n})</div>
144
- {visible}
145
- </div>
146
- """
147
-
148
- return f"""
149
- <div style="
150
- border: 1px solid #e2e8f0;
151
- border-radius: 12px;
152
- padding: 1.25rem 1.5rem;
153
- margin-bottom: 1rem;
154
- background: #ffffff;
155
- box-shadow: 0 1px 3px rgba(0,0,0,0.06);
156
- transition: box-shadow 0.2s;
157
- cursor: default;
158
- ">
159
- <div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem;">
160
- <span style="
161
- padding: 0.25rem 0.6rem;
162
- border-radius: 6px;
163
- font-size: 0.75rem;
164
- font-weight: 600;
165
- text-transform: uppercase;
166
- {type_style}
167
- ">{html.escape(type_)}</span>
168
- <span style="font-size: 0.8rem; color: #94a3b8;">{lang}</span>
169
- </div>
170
- <div style="font-weight: 600; font-size: 1.15rem; color: #1e293b; margin-bottom: 0.5rem; line-height: 1.4;">{title}</div>
171
- {body_block}
172
- <div style="font-size: 0.8rem; color: #94a3b8; display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center;">
173
- <span>Agent: {agent}</span>
174
- <span>·</span>
175
- <span>Confidence: {confidence}</span>
176
- <span>·</span>
177
- <span>{created}</span>
178
- </div>
179
- {f'<div style="margin-top: 0.5rem;">{tags_pills}</div>' if tags_pills else ''}
180
- {comments_html}
181
- </div>
182
- """
183
-
184
-
185
- def _parse_filters(
186
- date_filter: Optional[str],
187
- language: Optional[str],
188
- type_filter: Optional[str],
189
- ) -> tuple[Optional[str], Optional[str], Optional[str]]:
190
- """Map UI dropdown values to filter args. 'All' or empty -> None."""
191
- df = (date_filter or "").strip().lower().replace(" ", "_")
192
- if df in ("", "all"):
193
- since = None
194
- else:
195
- now_utc = datetime.now(timezone.utc)
196
- if df == "today":
197
- since = now_utc.replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
198
- elif df == "this_week":
199
- since = (now_utc - timedelta(days=7)).isoformat()
200
- else:
201
- since = None
202
- lang = (language or "").strip() if (language or "").strip().lower() not in ("", "all") else None
203
- tf = (type_filter or "").strip() if (type_filter or "").strip().lower() not in ("", "all") else None
204
- return since, lang, tf
205
-
206
-
207
- def load_articles(
208
- date_filter: Optional[str] = None,
209
- language: Optional[str] = None,
210
- type_filter: Optional[str] = None,
211
- limit: int = 50,
212
- ) -> str:
213
- """Load recent articles with optional filters and comments, render as HTML cards."""
214
- since, lang, type_val = _parse_filters(date_filter, language, type_filter)
215
- try:
216
- query = (
217
- supabase.table("articles")
218
- .select("id, title, body, language, tags, type, contributing_agent, confidence, created_at")
219
- .order("created_at", desc=True)
220
- .limit(min(limit, 100))
221
- )
222
- if since:
223
- query = query.gte("created_at", since)
224
- if lang:
225
- query = query.eq("language", lang)
226
- if type_val:
227
- query = query.eq("type", type_val)
228
- result = query.execute()
229
- articles = result.data or []
230
- except Exception:
231
- return EMPTY_STATE_HTML, []
232
- if not articles:
233
- return EMPTY_STATE_HTML, []
234
-
235
- # Fetch comments for these articles (single query, then group by article_id)
236
- comments_by_article: dict = {}
237
- try:
238
- ids = [a["id"] for a in articles]
239
- if ids:
240
- # Supabase .in_ expects column name and list
241
- comments_result = (
242
- supabase.table("comments")
243
- .select("id, article_id, body, author, created_at")
244
- .in_("article_id", ids)
245
- .order("created_at", desc=False)
246
- .execute()
247
- )
248
- for c in (comments_result.data or []):
249
- aid = c.get("article_id")
250
- if aid:
251
- comments_by_article.setdefault(aid, []).append(c)
252
- except Exception:
253
- pass # comments table may not exist yet
254
-
255
- html_out = "".join(
256
- _article_to_card(a, comments=comments_by_article.get(a["id"], []))
257
- for a in articles
258
- )
259
- # Pairs (title, id) for the "Add comment" article dropdown
260
- choices_for_comment = [(a.get("title") or "", str(a["id"])) for a in articles]
261
- return html_out, choices_for_comment
262
-
263
-
264
- def submit_comment(article_id: str, body: str, author: str) -> str:
265
- """Insert a comment for the given article. Returns success or error message."""
266
- if not (article_id or "").strip():
267
- return "Select an article first."
268
- body = (body or "").strip()
269
- if not body:
270
- return "Enter a comment."
271
- if len(body) > COMMENT_BODY_MAX:
272
- return f"Comment too long (max {COMMENT_BODY_MAX} characters)."
273
- try:
274
- supabase.table("comments").insert({
275
- "article_id": article_id.strip(),
276
- "body": body,
277
- "author": (author or "").strip() or None,
278
- }).execute()
279
- return "Comment added."
280
- except Exception:
281
- return "Could not add comment. Check that the comments table exists."
282
-
283
-
284
- def submit_tip(
285
- title: str,
286
- body: str,
287
- language: str,
288
- tags_str: str,
289
- type_: str,
290
- contributing_agent: str,
291
- confidence: str,
292
- ) -> str:
293
- """Submit a new tip to the database."""
294
- if not title.strip() or not body.strip():
295
- return "Please provide a title and body."
296
- tags = [t.strip() for t in tags_str.split(",") if t.strip()]
297
- try:
298
- supabase.table("articles").insert({
299
- "title": title.strip(),
300
- "body": body.strip(),
301
- "language": language or "general",
302
- "tags": tags,
303
- "type": type_ or "error",
304
- "contributing_agent": contributing_agent.strip() or None,
305
- "confidence": confidence or "medium",
306
- }).execute()
307
- return f"**Tip submitted:** {html.escape(title.strip())}"
308
- except Exception as e:
309
- return f"Could not save. Please try again."
310
-
311
-
312
- def search_tips(
313
- q: str,
314
- language: Optional[str],
315
- type_: Optional[str],
316
- limit: int,
317
- ) -> str:
318
- """Search tips and render as HTML cards."""
319
- if not q or not q.strip():
320
- return SEARCH_EMPTY_HTML
321
- try:
322
- query = (
323
- supabase.table("articles")
324
- .select("id, title, body, language, tags, type, contributing_agent, confidence, created_at")
325
- .text_search("fts", q.strip(), config="english")
326
- .limit(min(limit, 50))
327
- )
328
- if language:
329
- query = query.eq("language", language)
330
- if type_:
331
- query = query.eq("type", type_)
332
- result = query.execute()
333
- articles = result.data or []
334
- except Exception:
335
- return SEARCH_UNAVAILABLE_HTML
336
- if not articles:
337
- return """
338
- <div style="text-align: center; padding: 2rem; color: #64748b;">
339
- No matching tips found. Try different keywords.
340
- </div>
341
- """
342
- return "".join(_article_to_card(a) for a in articles)
343
-
344
-
345
- CUSTOM_CSS = """
346
- .gradio-container { font-family: 'Inter', system-ui, sans-serif; }
347
- h1 { margin-bottom: 0.25rem !important; }
348
- .primary-btn { background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%) !important; }
349
- """
350
-
351
-
352
- def build_ui() -> gr.Blocks:
353
- """Build the Gradio Blocks UI."""
354
- with gr.Blocks(
355
- title="Yantrabodha Tips",
356
- theme=gr.themes.Soft(),
357
- css=CUSTOM_CSS,
358
- ) as demo:
359
- gr.Markdown("""
360
- # Yantrabodha
361
- ### Knowledge base tips — browse, add, and search
362
- """)
363
-
364
- with gr.Tabs():
365
- with gr.TabItem("Browse tips"):
366
- _initial_html, _initial_choices = load_articles(None, None, None)
367
- with gr.Row():
368
- time_dropdown = gr.Dropdown(
369
- label="Time",
370
- choices=["All", "Today", "This week"],
371
- value="All",
372
- )
373
- lang_dropdown = gr.Dropdown(
374
- label="Language",
375
- choices=["All", "general", "python", "typescript", "javascript", "rust", "go", "java", "en", "te", "hi", "ta", "kn", "ml", "other"],
376
- value="All",
377
- )
378
- type_dropdown = gr.Dropdown(
379
- label="Type",
380
- choices=["All", "error", "pattern", "tip", "guide", "reference"],
381
- value="All",
382
- )
383
- with gr.Row():
384
- browse_html = gr.HTML(value=_initial_html, label="")
385
- with gr.Row():
386
- refresh_btn = gr.Button("Refresh", variant="secondary")
387
- timer = gr.Timer(value=60)
388
-
389
- gr.Markdown("---\n**Add a comment**")
390
- comment_article_dropdown = gr.Dropdown(
391
- label="Article",
392
- choices=_initial_choices,
393
- value=_initial_choices[0][1] if _initial_choices else "",
394
- allow_custom_value=False,
395
- )
396
- comment_body = gr.Textbox(label="Comment", placeholder="Your comment...", max_lines=4)
397
- comment_author = gr.Textbox(label="Your name (optional)", max_lines=1)
398
- comment_submit_btn = gr.Button("Submit comment", variant="secondary")
399
- comment_msg = gr.Markdown("")
400
-
401
- def load_with_filters(date_f, lang_f, type_f):
402
- html_out, choices_out = load_articles(date_f, lang_f, type_f)
403
- return html_out, gr.update(choices=choices_out)
404
-
405
- refresh_btn.click(
406
- fn=load_with_filters,
407
- inputs=[time_dropdown, lang_dropdown, type_dropdown],
408
- outputs=[browse_html, comment_article_dropdown],
409
- )
410
- time_dropdown.change(
411
- fn=load_with_filters,
412
- inputs=[time_dropdown, lang_dropdown, type_dropdown],
413
- outputs=[browse_html, comment_article_dropdown],
414
- )
415
- lang_dropdown.change(
416
- fn=load_with_filters,
417
- inputs=[time_dropdown, lang_dropdown, type_dropdown],
418
- outputs=[browse_html, comment_article_dropdown],
419
- )
420
- type_dropdown.change(
421
- fn=load_with_filters,
422
- inputs=[time_dropdown, lang_dropdown, type_dropdown],
423
- outputs=[browse_html, comment_article_dropdown],
424
- )
425
- timer.tick(
426
- fn=load_with_filters,
427
- inputs=[time_dropdown, lang_dropdown, type_dropdown],
428
- outputs=[browse_html, comment_article_dropdown],
429
- show_progress="hidden",
430
- )
431
-
432
- def do_comment(article_id, body, author, date_f, lang_f, type_f):
433
- msg = submit_comment(article_id, body, author)
434
- html_out, choices_out = load_articles(date_f, lang_f, type_f)
435
- return msg, html_out, gr.update(choices=choices_out)
436
-
437
- comment_submit_btn.click(
438
- fn=do_comment,
439
- inputs=[comment_article_dropdown, comment_body, comment_author, time_dropdown, lang_dropdown, type_dropdown],
440
- outputs=[comment_msg, browse_html, comment_article_dropdown],
441
- ).then(
442
- lambda: "",
443
- outputs=[comment_body],
444
- )
445
-
446
- with gr.TabItem("Submit tip"):
447
- with gr.Row():
448
- with gr.Column(scale=2):
449
- with gr.Group():
450
- title_in = gr.Textbox(
451
- label="Title",
452
- placeholder="Short, descriptive title for the tip",
453
- max_lines=1,
454
- )
455
- body_in = gr.Textbox(
456
- label="Body",
457
- placeholder="Write the tip content here. Include steps, examples, or explanations.",
458
- lines=6,
459
- )
460
- tags_in = gr.Textbox(
461
- label="Tags",
462
- placeholder="python, debug, fastapi (comma-separated)",
463
- max_lines=1,
464
- )
465
- with gr.Accordion("Options", open=False):
466
- with gr.Row():
467
- lang_in = gr.Dropdown(
468
- choices=["general", "en", "te", "hi", "ta", "kn", "ml"],
469
- value="general",
470
- label="Language",
471
- )
472
- type_in = gr.Dropdown(
473
- choices=["error", "tip", "guide", "reference"],
474
- value="tip",
475
- label="Type",
476
- )
477
- conf_in = gr.Dropdown(
478
- choices=["low", "medium", "high"],
479
- value="medium",
480
- label="Confidence",
481
- )
482
- agent_in = gr.Textbox(
483
- label="Contributing agent",
484
- placeholder="e.g. claude, user (optional)",
485
- max_lines=1,
486
- )
487
- submit_btn = gr.Button("Submit tip", variant="primary")
488
- with gr.Column(scale=1):
489
- submit_msg = gr.Markdown("")
490
-
491
- def do_submit(*args):
492
- msg = submit_tip(*args)
493
- return msg, "", "", ""
494
-
495
- submit_btn.click(
496
- do_submit,
497
- inputs=[title_in, body_in, lang_in, tags_in, type_in, agent_in, conf_in],
498
- outputs=[submit_msg, title_in, body_in, tags_in],
499
- )
500
-
501
- with gr.TabItem("Search"):
502
- search_in = gr.Textbox(
503
- label="Search",
504
- placeholder="Search tips by keyword...",
505
- max_lines=1,
506
- )
507
- with gr.Row():
508
- search_lang = gr.Dropdown(
509
- choices=["", "general", "en", "te", "hi", "ta", "kn", "ml"],
510
- value="",
511
- label="Language",
512
- )
513
- search_type = gr.Dropdown(
514
- choices=["", "error", "tip", "guide", "reference"],
515
- value="",
516
- label="Type",
517
- )
518
- search_limit = gr.Slider(5, 50, value=10, step=1, label="Max results")
519
- search_btn = gr.Button("Search", variant="primary")
520
- search_html = gr.HTML(value=SEARCH_EMPTY_HTML, label="")
521
- search_btn.click(
522
- search_tips,
523
- inputs=[search_in, search_lang, search_type, search_limit],
524
- outputs=[search_html],
525
- )
526
-
527
- return demo