File size: 11,844 Bytes
a32396c
3be11fc
 
 
e76be68
3be11fc
 
e76be68
3be11fc
 
e76be68
e5dd183
d059378
3be11fc
 
 
 
 
 
 
 
 
 
 
 
 
e76be68
 
 
 
 
 
 
 
 
3be11fc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d059378
3be11fc
 
 
 
 
 
 
 
c6b9676
e5dd183
3be11fc
 
e5dd183
 
3be11fc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e5dd183
3be11fc
 
 
 
 
 
d059378
3be11fc
 
 
 
 
 
 
 
 
 
061f058
3be11fc
 
 
 
 
 
061f058
3be11fc
 
 
 
 
 
 
 
 
 
 
 
 
03901aa
3be11fc
 
 
 
 
d059378
03901aa
3be11fc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d059378
3be11fc
 
 
 
 
 
 
 
 
 
 
 
 
d059378
3be11fc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
047f73e
3be11fc
 
047f73e
3be11fc
 
 
 
 
 
 
 
 
d059378
e76be68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169c9a7
 
 
e76be68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d059378
3be11fc
 
e76be68
 
 
3be11fc
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
import os
import re
import json
import time
from typing import Dict, Any, List, Optional
from urllib.parse import urlparse, parse_qs

import gradio as gr
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware

try:
    from huggingface_hub import HfApi
    HF_AVAILABLE = True
except Exception:
    HfApi = None
    HF_AVAILABLE = False

# Directory to store compiled uploads
BASE_DIR = os.path.dirname(__file__)
UPLOAD_DIR = os.path.join(BASE_DIR, "uploads")
os.makedirs(UPLOAD_DIR, exist_ok=True)

app = FastAPI(title="Data Collection Server", description="Receives text/URLs from captioning/image servers, groups by course, compiles JSON and optionally uploads to HuggingFace.")

# Enable CORS for Gradio
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# In-memory store for course data
courses: Dict[str, Dict[str, Any]] = {}

URL_RE = re.compile(r"https?://[\w\-\./?%&=:@,+~#]+")
DONE_RE = re.compile(r"\b(done|finished|completed|complete)\b", re.IGNORECASE)

HF_TOKEN = os.getenv("HF_TOKEN")
HF_DATASET_REPO = os.getenv("HF_DATASET_REPO")  # e.g. "username/dataset-name"


def extract_urls(text: str) -> List[str]:
    return URL_RE.findall(text or "")


def extract_course_from_url(url: str) -> str:
    try:
        parsed = urlparse(url)
        qs = parse_qs(parsed.query)
        course = qs.get("course") or qs.get("Course") or qs.get("COURSE")
        if course:
            return course[0]
    except Exception:
        pass
    return None


def now_ts() -> str:
    return time.strftime("%Y%m%dT%H%M%S")


async def parse_request(request: Request) -> Dict[str, Any]:
    """Read incoming request in any format and return a dict with keys: text, json, form, headers"""
    payload = {"text": "", "json": None, "form": {}, "headers": dict(request.headers)}

    # Try JSON
    try:
        body = await request.json()
        payload["json"] = body
        # if it's a simple string payload inside JSON
        if isinstance(body, str):
            payload["text"] = body
        elif isinstance(body, dict):
            # flatten likely fields
            for k in ["text", "caption", "message", "body", "content"]:
                if k in body and isinstance(body[k], str):
                    payload["text"] = body[k]
                    break
            # allow explicit course field
            if "course" in body and isinstance(body["course"], str):
                payload["course"] = body["course"]
    except Exception:
        # not JSON - try raw body
        try:
            raw = (await request.body()).decode("utf-8", errors="ignore")
            payload["text"] = raw
        except Exception:
            payload["text"] = ""

    # Try form (for multipart/form-data)
    try:
        form = await request.form()
        for k, v in form.multi_items():
            # take first text-like value
            payload["form"][k] = str(v)
            if k in ("text", "caption", "message", "content") and not payload["text"]:
                payload["text"] = str(v)
            if k == "course":
                payload["course"] = str(v)
    except Exception:
        pass

    # If no text yet but JSON is a list or similar, stringify (best-effort)
    if not payload["text"] and payload.get("json") is not None:
        try:
            payload["text"] = json.dumps(payload["json"])
        except Exception:
            payload["text"] = str(payload["json"])

    return payload


def add_entry(course: str, entry: Dict[str, Any]):
    c = courses.setdefault(course, {"items": [], "last_updated": None})
    c["items"].append(entry)
    c["last_updated"] = time.time()


def compile_course(course: str) -> str:
    """Compile course data to JSON file and optionally upload to HuggingFace. Returns path to saved file."""
    if course not in courses:
        raise ValueError(f"Unknown course: {course}")

    data = {
        "course": course,
        "compiled_at": now_ts(),
        "count": len(courses[course]["items"]),
        "items": courses[course]["items"],
    }

    filename = f"{course}_{now_ts()}.json"
    safe_filename = re.sub(r"[^a-zA-Z0-9_\-\.]+", "_", filename)
    path = os.path.join(UPLOAD_DIR, safe_filename)

    with open(path, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)

    # Optionally upload to HuggingFace
    if HF_TOKEN and HF_DATASET_REPO and HF_AVAILABLE:
        try:
            api = HfApi()
            # upload path at root of repo with same filename
            api.upload_file(
                path_or_fileobj=path,
                path_in_repo=safe_filename,
                repo_id=HF_DATASET_REPO,
                repo_type="dataset",
                token=HF_TOKEN,
            )
        except Exception as e:
            # Log but don't fail the compile
            print(f"[WARN] HuggingFace upload failed: {e}")

    # After compiling, clear stored items for that course
    courses[course]["items"] = []
    return path


@app.post("/submit")
async def submit(request: Request):
    """Receive any data (text, JSON, form). Will try to extract course and URLs and store entries.
    If message contains 'done' or similar, it will compile the course to JSON (and upload if configured).
    """
    payload = await parse_request(request)
    text = (payload.get("text") or "").strip()

    # Collect urls found
    urls = extract_urls(text)

    # Determine course from payload (explicit field) or from any URL
    course = payload.get("course")
    if not course:
        for u in urls:
            c = extract_course_from_url(u)
            if c:
                course = c
                break

    if not course:
        course = "unknown_course"

    entry = {
        "timestamp": now_ts(),
        "text": text,
        "json": payload.get("json"),
        "form": payload.get("form"),
        "urls": urls,
        "headers": {k: v for k, v in payload.get("headers", {}).items() if k.lower() in ("user-agent", "host", "content-type")},
    }

    add_entry(course, entry)

    # Detect completion
    if DONE_RE.search(text):
        try:
            path = compile_course(course)
            return JSONResponse({"status": "compiled", "course": course, "path": path})
        except Exception as e:
            raise HTTPException(status_code=500, detail=str(e))

    # Detect explicit 'course change' in URLs (if a URL contains a different course than stored) -- best-effort
    # If a URL indicates a different course and there were previous items, compile previous course first
    # Example: previous stored course is same; we don't track per-source last course, so skip this more complex behavior for now

    return JSONResponse({"status": "stored", "course": course, "count": len(courses[course]["items"])})


@app.get("/status")
async def status():
    summary = {c: {"count": len(v["items"]), "last_updated": v["last_updated"]} for c, v in courses.items()}
    return {"courses": summary}


@app.post("/compile")
async def compile_endpoint(course: str = None):
    """Force compile a course. If course is not provided and only one exists, compile that one."""
    if not course:
        if len(courses) == 1:
            course = next(iter(courses.keys()))
        else:
            raise HTTPException(status_code=400, detail="Provide course query parameter when multiple courses exist.")

    try:
        path = compile_course(course)
        return {"status": "compiled", "course": course, "path": path}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


@app.get("/debug/{course}")
async def debug_course(course: str):
    if course not in courses:
        raise HTTPException(status_code=404, detail="Course not found")
    return courses[course]


# Gradio Interface
def get_course_summary():
    """Get a formatted summary of all courses and their items"""
    if not courses:
        return "No courses available"
    
    summary = []
    for course, data in courses.items():
        items = data["items"]
        last_updated = data["last_updated"]
        last_updated_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(last_updated)) if last_updated else "never"
        
        summary.append(f"Course: {course}")
        summary.append(f"Items: {len(items)}")
        summary.append(f"Last Updated: {last_updated_str}")
        if items:
            summary.append("\nLast 3 captions:")
            for item in items[-3:]:
                text = item.get("text", "").strip()
                urls = item.get("urls", [])
                summary.append(f"- {text[:200]}... ({len(urls)} URLs)")
        summary.append("\n" + "-"*40 + "\n")
    
    return "\n".join(summary)

def compile_course_ui(course_name: str) -> str:
    """Compile a course from the Gradio UI"""
    if not course_name:
        return "Please enter a course name"
    try:
        path = compile_course(course_name)
        return f"Successfully compiled course to {path}"
    except Exception as e:
        return f"Error compiling course: {str(e)}"

def add_test_caption(course_name: str, caption_text: str) -> str:
    """Add a test caption through the Gradio UI"""
    if not course_name or not caption_text:
        return "Please enter both course name and caption text"
    
    entry = {
        "timestamp": now_ts(),
        "text": caption_text,
        "urls": extract_urls(caption_text),
        "json": None,
        "form": {},
        "headers": {"user-agent": "Gradio UI"},
    }
    
    add_entry(course_name, entry)
    return f"Added caption to course '{course_name}'. Total items: {len(courses[course_name]['items'])}"

def create_gradio_interface():
    """Create the Gradio interface tabs and components"""
    with gr.Blocks(title="Data Collection Server UI") as interface:
        gr.Markdown("# Data Collection Server Monitor")
        
        with gr.Tab("Course Status"):
            status_output = gr.Textbox(label="Current Status", value="Loading...", lines=15)
            refresh_btn = gr.Button("Refresh Status")
            refresh_btn.click(get_course_summary, outputs=status_output)
            
            # Add load event inside the Blocks context
            gr.on(triggers="load", fn=get_course_summary, outputs=status_output)
        
        with gr.Tab("Manual Controls"):
            gr.Markdown("### Compile Course")
            with gr.Row():
                compile_course_input = gr.Textbox(label="Course Name")
                compile_btn = gr.Button("Compile to JSON")
            compile_output = gr.Textbox(label="Compile Result", lines=3)
            compile_btn.click(compile_course_ui, inputs=compile_course_input, outputs=compile_output)
            
            gr.Markdown("### Add Test Caption")
            with gr.Row():
                test_course_input = gr.Textbox(label="Course Name")
                test_caption_input = gr.Textbox(label="Caption Text", lines=3)
            test_btn = gr.Button("Add Caption")
            test_output = gr.Textbox(label="Result", lines=2)
            test_btn.click(
                add_test_caption,
                inputs=[test_course_input, test_caption_input],
                outputs=test_output
            )
    
    return interface

# Mount Gradio app to FastAPI
interface = create_gradio_interface()
app = gr.mount_gradio_app(app, interface, path="/ui")

if __name__ == "__main__":
    import uvicorn
    port = int(os.getenv("PORT", "8000"))
    print(f"[INFO] Server starting on port {port}")
    print(f"[INFO] FastAPI docs: http://localhost:{port}/docs")
    print(f"[INFO] Gradio UI: http://localhost:{port}/ui")
    uvicorn.run(app, host="0.0.0.0", port=port)